From 1335156914e2f8c7531be88503ef813492a7d31c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Thu, 7 May 2026 15:39:01 -0400 Subject: [PATCH 01/21] feat: add Terraform plan parsing and validation logic with test cases --- .dockerignore | 2 +- example_report.pdf | Bin 0 -> 10936 bytes internal/iac/terraform.go | 150 +++++++++++ internal/iac/terraform_test.go | 251 ++++++++++++++++++ internal/iac/testdata/plan_empty.json | 5 + internal/iac/testdata/plan_mixed.json | 72 +++++ internal/iac/testdata/plan_replace.json | 24 ++ internal/iac/testdata/plan_simple_create.json | 22 ++ 8 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 example_report.pdf create mode 100644 internal/iac/terraform.go create mode 100644 internal/iac/terraform_test.go create mode 100644 internal/iac/testdata/plan_empty.json create mode 100644 internal/iac/testdata/plan_mixed.json create mode 100644 internal/iac/testdata/plan_replace.json create mode 100644 internal/iac/testdata/plan_simple_create.json diff --git a/.dockerignore b/.dockerignore index 84f15d7..605eb08 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,7 +14,7 @@ web/node_modules/ web/dist/ internal/api/dist/ -report.pdf +example_report.pdf cloudoracle-report.pdf cloudoracle cloudoracle.exe diff --git a/example_report.pdf b/example_report.pdf new file mode 100644 index 0000000000000000000000000000000000000000..626b0c9fef264f9b77e945277542fb6bf0de5113 GIT binary patch literal 10936 zcmbVy1yq#l_C5#*0@5KN4AKe=GjvExcOw!53=Km|hkzi`QqrJwN|&T`N{4hw=g{#R zJpS)F=bryv>#jSC^}VzA`@MU=vG>EC?|bN#B_vrvY#f;Mt(C1+m>d9h0L;h|Q&147 z?q&xC0F@zTP)x+t0cz_603mu+F@dU3N0_sNG1L(N{zD-SvvorBI|4ZGWFa9;sIAGJ zl{@jbHAxF=C#VAeC}|CGf=WP*VI~OU@=#kdCvyOp3(SKUiA<^2e%{LcN%7aBqbjam9h#7ITgP~YpidV zBYju59F#qBi^8vogl1KqsMh>Y5n~QaySSy=owHu-JoQjHBxo0kW|ljAWz?_?CHcbH z84%CeX=3m#l(E@ZjM@5hzvk;Db_{!53-X*b4GA4VWcHg<)GGR`OPj2jLoM3(n2WfA z7R%m~-fuctue?t?grj>nW%8JG-f|8sm#x@;A+!j15*^)BC{5P>WYEw4Pm|x6{WoZr zV}h}-Xnk7mhdF7#v0Z?7=VCsZLCO)@|EVFd=+_}^oug8ynC!)9&mb*X&qUQ^_?_Qb z@@#Uvl2d6gtwsoTA_${;kg$@n5?b2ev7{-W)1uFnWr=e_EHMR-YBU7;fWjVNp?S=Z z`-l0zW^E})MV+)wCPcFN8b z1gw*z$GBLY4bpt^7$}dyaDUj9Qym<}ym8HI1Rbd;OH>`v_DotAW*{=ATh_CrJ_wbp z&CzN$K09wy5{CJ<@Aod(>TLU5pv3hWp?yahx6bRsAE} z!C;IdBlE_&)f=5tPr^_|Ll1S5mM1`d8NswF{hItyW%AV+;2gM&G%NxzMU-IG83;!$(Qy8r6^ zANUVA6Y^Bd{@EI>c21a}VdoMvJROhB zZ^GNRqT>u)M>~#DHs0TIE6nS)neVBYMg7y?J$>8hv-&c!r!o6CK}Nx|p3AQM5^Wdw z$(M8a5;%_SX>>xBg{L6tYPhdKa?5ml&M;}D**4hiawctvUjwdYi9$d9!2v_yN)f9= z9sGgxxh3Jc1s zAH@XE6=hMo=G)i3?@cY;@9Y>0B`h*gW9y&=69n<`9LI_l=&Gd$cC(y*ooA)i_kGuw z(x6FYhhnCa2d=0U$OPMcU-OBLqknYuDQ$>xYg?WLmJYnKBx5wb1NZtR z1-98xw^L`v>wNnCMyug{duRrGPhRHC{V-?4$EkJVJyi9~ti8i&6hf-(+H$L24UaS> zg4$bfGcuUpYHkf5R@^_SGcPh|xEz@ZTJg9nET}q(^3GKvzBn4ceZSSGO$l`r!6Mbr z7&1q%)7c#lMns}<`8U~fTFW}YZHwWOuHtn zz}9TnP4Lmy9?I_NBwWwGN+7hptz>wo%rhp6^O0=YkW)eTXPMjY(D3)3?0W)Esv6p@ z9zN5Y=pJLy1aXW$m+tWqTHy(VEqUG@(E8vF(qN*FnRNTcouQM{nT zRS;sSo3J|!4t7lkkwh`2b*?^m2ToYH`RPF@)KE{rryx!BP&z8o#IH+7u}*G*PRvmg zExd;ANjKMga&$~Tde?_@TT@|p-0bYPXv!s8d@FDX~) zWP{|Jv>JE^W#n@jB4$y`)lAkq$FO|3>z)KF%A~5?esEw9YI!P1W8q#YE6YStIGlt( zXDup0XWV@4%vZSFWSBR7k!THzX|7x2N7)JkxAx46w=$VMG1gi#jPa!m1≷o_Yz2 zCOTN>x6mPTC79rhC7F;v2EFmj`^@KQ71vDJ2W|_sn67m)nUi|g6s}kPxR1cj5v{?E zkTlt?Kie+uxj!&Z$+5{1+uw-cCS9nPBRcSUrRu_ui?kulYED-4`a$83=b!Dr$i0p+ z*aGEJM{bR#0bqI5%7`|-haQgNm~uh5mVANCqt#3g&)J7>mM52J2^-T9&c5+byH4dI zy~2V^Y1ev)kS*%>z}%apiq(B2=9HVHiV!Vp-aLm^zA)HqbkE)q?UlOEy)?BnN8$5x zlf!cLNOjNZQ-q*HaQV2ksrcH>y#U9tiLpQMM(h$li%R>OMO-_Gdfw}#Xr80#nYUz_ zIXM-{2WEmQf2vB=D^P>G?dEB7Y)?e92Z=C6-}Rk~Rb6#&D({pFo)}UxndR7?&W-3p zKTgsMtW~KP_uw)Xqp0AcgRz@*Ps3t$Z<9Gyp>)ls!kHmDt0;C^hqU{t_M!|WE9k2z zw#AFb8blv{^%EFFt+GiYa};wRn0jncLv-8~+ZxfO+Zyy! z6H_>IEfBXEAvg0aA}qMSZ$(%LSpPB~*;M^A5Hgs#SP-GxCKP!UR5!EGH!xY^Ml1VV z;!UTFWciBjDvBdw21jv+$d#(+_*^BPNT%0bb_}!L?P*BsF}S?9ExLD$3q7ItlyOTe z))Z2@f(TC>Ag_F?AwE9}r{mM`g1?;*{3Yf9=u&Z zCh`{QbB6kSq!ulAM<3#^mHH9-1DRW^ioSCF`)qxh;H=u0nZ4)~dAFCQ_RL`NRULA( znAM6&s7lmOLqK;u_3h5PuCo1}2uD!@%vX_uKUO=08z-N)=-xI~+%89;pD9q`O-3vQ z?J&CjXx)2#i;IMGd+Uo`EO=KN{;u?XSBYGIR*C;h&B^`un)6QlZ)#3nPQJg^oPEiI z(aTl%L3>9;yz===C|}NSTdTLH?UVD>t@l=l)h5 zds&Yz@w9sb4RLi(R@+h*#4t|AQdYz;7+jT~ULWnBH7P^wSdgn+l-ta!>yCy4hr&!h zz15VXmdv@2FIl}4rXG&QhU$@EMAlwpB!pW0ygVAKot(_oY7$}sn;MiqOgN4D>Sd@Y ze`~WM=K=9)n6MC)|!C8^o#SiWh?pq`pA z=#!wQ#Jvcu2(QOf>28kIeKH>rG@&PKnmqEOzljH9m!3+Qud^R*cX3(iV^-0kd-(WE zHtYegn0!`uyGONf>YU)-y1AqhPIQQFI|q#&Et|LHX`P3gAX`+ETj?987T|pg@~1*} z1?w40wUO%?PYl@=#p(Ao!F$u%JG|;QWgb_Mn>>pIB#WCYjHeGjgidqyxIKGWRFoLC zZhx=uh*vaHwz6TY*VVX5Bnxye_vlfJvPI~M>(|vLA?KjZEx&usM*ZaGs^XS;A|YQMIPaP|Q`p7x~RrYqJ%a$)peHe84d16>)+a0P=k~galm$ zoTgH8nImaatr^nNzsj40&hXvSG(5>zlK#l|N#bB`XPOgjAfCb3ly~b~_ktzhA6v%m|$_J|di1`fHulx~`v+?Ni8K<-;`cq#_3-MsV znE~9(Q1Yj{zmy3c%@BA&ok27HscgGj3CYnMTAM-*-(HSasufrD&PrqHpd1~`dDc0a z<%Idp=M%;A_BHGQly`1w*A;`h>{yo#0hxIQ>8VkCX|B}`a_xZzhr8G974r2B5^W(` zBaK~kMuTdN>9ZcTFEd@%EvNkg=nX6ebM-#?$E#LmjBSkxf!tb(UX&RzirS~#=W!}(Puq=Co`J* zk$z`i9AOG`3`a2Yq))s8<>51Z4S}9w3m$%39BC>39O7!6F&xBNWIZHz$jK@`w7AXd z_q!MpRGOK}2&4y3b8LP-KU!7fPWed?`SUr-p0j)Q^Ejuf*`bnvE_i)u~k3MJtJ9o?_hH#lHckTP9+qjhK)U%HDbo)DB#Svxk#7P+ zrJG|7%iB|zaGJUM?P0YWUi{x?G&>ZXJsU`I58eer4L%N*LPmH;Y!oJJ#tDUJU?FKZ z2UsSHfOutz14OiZK0m1Sa8De`$`Yb^x5-h~Fv>`-*_eNUrmJ5OV72*mbdjU1`9a9LO`Wxd`$ zOv)ytVB~i$qKcG&lj*49G9mos=lwGCWYh0$Y)QU%OLl?Q+oYL2OT(7q1{{l9@9X}7YIBkC?B_XD zQjg2CLd2A>Rj==njJ>X+Mt@c^iV~ge?Z=XbPNZ;sPeTn?I`IPSkI`IkIquAxI+)d) z;CLI`ytHX}N6_SS#3mNwg+yy~$v3mnVwDbo#jJ6yl;m#>7T%Tp{`=VWfrAAX# za$Ob>DM6L_*j)P{e_F*wp+04I;ClrHh3;j73jorsHO*l;Aq~}CeB)sHb%2pU#ltkN zLL3qMp<9+y}(>n0jaaDOS?j$b211TF@?)5O$Pjz>uRt_kg4D6x|b zX;-U@P=@&uX)eTa_W9mLo3}ScRM8p#V>dZ!QljS@e-FHt(l0HTvM5EEicqhg6|BC-}v?S{}x#D*) zOQ#0b^k-{+fiM2ZB9j`HdT#trw+x{4MC#Uz*F~Pqr{zivE``%*Cji3YFvrum`m!RD zj_HCemv=HT2!z|fi(q>GNqM80R(7yKj>BvNsm)R=Hax>>I5u4_f(LD=gqh$0R%3Lk zioI}&d$oqq_F9;l&q3K0%VQS9$^zNWA+8NE zN3YAuKF$%1hGOJ-l^9e0$kK)QVbWYCFLr-&%=vzL28D|^I?*@>=EI~+>iqkNrlHKq z7gAXuBlUemD{ch_%j%v-y`>cX7{ltRXO-QS3T7p>i@mA;GA}F~0c_(f6^7@`-q8D$ zfv-QV3`Ua9Un<_xJ}=A-(uk8gTN8IUQR4N!df%QTq{?KW+uay(S=n~}p=(dr_@T%A z*KiiE)%`WMTZbO%w~8Cyy@MgKqr?Zk?;&z_FE?KQ=FHp=3(k6GiQeAGT(uB&cw_JI5Z#Zx~^~H^G9=(w4HRq{0 z-k|>(+v4~jh)el`&!gIhZ};s%^V@46Fe1oy@~dFkNNS4rqp9Art#=CX(H(6>KBo?X z&cN#~#rlKD+sQoJw4VIqw{pV7ih3{M+4E;%%1xg%Hc++LSJ;Y{rYtq-U#GR^+rMfZ z*uI^}A6}2j0TzX)P*v+O`Y07Vu|pHz9e$cxWU8@$La6;PPR

$04r;7C39oV>{xu z8Z?ewMa*9@S0Q%)JM87G0RH`P2b$seQM`DPne3n!uiR1$dk&Elno2t24~9}Y{A`5E zUMpSVw$0<}-Ex+_^&-?_hj)0N?9*Nz`gwVr&LiDb?Pjn0^Zr@2^ZZ$7|1VWL@87HT zJMq7$+CgAm@L#L;A0L%qGtva@dpAVRlW5j}U3F*p{Eu;c7&jIR!$)&PdmIH9Iin{Z zsG?n#&W{lw&dNuN6Z05vYhw{3LiQ>%*4sr<&do5TJ3H^)KF248h7vabcBv6U;duGz$OP9ix@f;H>9iP`J zS;+RpRX>J?cVC#8NTl@%YSBH!o7*}kR>tGgZtlj8At`L(2Y&X~2@t1WX!CjBen?By z`Hdh&O_rZmxM65%>*?rwCvViweW6#3XXwmC!9ZYsnI2WYP|Fnd+{2zmvrqgGc}wwx zF)IIU^$cd zN~EwcMtN)~`y!J`{I=Rfhf_I5fucw7fh$K4cs0*L%j``nBX>MTMi@l|o-b{4W9$=TjZMxX zpE%qzmwN+k>cz#F%(MPP z36Z>=a15p*G{}5GK9n^_WqQcMpXtAP09PJt~N{~ z^S1W~)b+71QVw05kEA(h1PFL5^r9)4`i^4yxh}qDGt zZsE8#vYEF`fn}lXR*j{YO-QUS+<47qpSkrM(kD^W-WJ*=?4V@`7Q>l6B?(Nsz4lg} z8vSk<_}CWx1xmq^gwMyu zs@E1uIo{Yo>8z?=x0S-22T|h@F{-2&MQ-^_AKYa1-1a)vERJ0XXOEo_N21j4`s0>f zDJxq@JZzumM7=IbU^@6<5>J=b=Cx1#S(W4t?L;mez4_1JYHu9SF1MbVZS;_Mpv(I* zbY`|Ky}iAaulwT1*9v(;wM6lSkIAy&L=j)ir|UQQzQR`%!>5bF-S%SBxwkfT*;nyuaG=`+ zYNzfZI{4JAsoHULN{~aRbXYPc)1_-pXEjoA?c?%~^oRx2Cw&*yA+y91^)uD3s+JHu z{!{cAgB88}wKBrYCIupok(_;c$&y}Cp z6gYpDHyVceTmzUL^0CJkI6sc)zX2F-mzP!k@$^bJ{iZA29-!f3y;WuLFn)F+-h0S^7{Y9eNjL2iqOs zR3T)q#T;|OzSy}WpbA7})CdX-ydi6VYvDH%cHyO^X+d8;?iX&}pqxAI-sMoSVQ*Pw zN>GtMWScC=t_=k)90wRane5?NklWY#a?*>zSaR#zn|)pM5(#wl++Qn?BBovY&7hh+ zF+^poIm~yVgu;ch$t|24N9%s1Ov?!Q|q2^V~RX?b21*ikJFFX)el{8wa0}jE=BX}#tb@|hO-;1H-dJMRPncF zO1y^1OA5(Z=*c-^K@s}ta};V&Uc%A-`Gk#elM(q<6n2ds_BoFer;nI?u2M@lYP$iB z+%wc_Vv=4O4Q)=kWz5+jQ$dCyi3fA#jJ@hFL9S8`eWS+N-$!l|WOp9Hoo)M^tPnRD z@kaTm?8M4~!u57s>*UcM&P9=PWC;edNK{rkKdps+t~;mTm`$_}VFtVVIzLqbZ$I}a zlMh_QzPymUz)>qXd_*))p_cLRQ{%uaq>gopXr#aY{QY>I@*BwB)Clyc-F|rA+KpPD zGLQ+jqMf2y|XmuOzMskD;&DbGOtu_D$`c?ZZoaZ2JJ^H9J1U9HO!S z{u#LFY3WTZN$7jqXsN5})xk(*`iyAiNnDxXfe+bMYEEnolb>K$<2!{jmlKM zAQb|bl9LTZ@^a0?fdl)Qh2#;tPO`K4ik52BdY?=~Ld(`kWwd8;_!{+s6AldL=D{5dZ+Fkr{}YBmcT|brxcNWf@GoWs0#ScI zBM05d{{;^7f&TVX`$xP10)|OD_O6)++Ar_1Dsu$gcF(cu#6g=&T@H#9^_5qmiqzx4 z?_AcsE<_!Yzs)Ssh}IF+gRsS`e3teWKZyrWGhk>DnoPH@cOPJfH?|puy-2B{RoBm= zQ)j7&{S3u;^s0iA*&R2PrnN9&WC|65!czep5~wEn!ATRgH^&B#?PXIWp6K-$)*c8& zK9z8Y)>V?EJ2vUbtzmCI2w&F{>8SPRqZ|~mVBpeT|7gc9w=au%pW6GgdQluT4Y8U8 zP<)7-%^>Ryv^!2?d%AamJ@9=g!&w@XJR;8K18IqwmL0$U>4&HtW;w2Onc|b7Ler24 z^L4ML%eV@yDdhD2&B9T=Y&`Ua0!fK`(YSoZ) zOHY3Dc>+7B8q7!$bd0SVM^CVFffL7zl)fA@|X4ZKs$<~k8 zw9u7>2L8f2kaDy99z|ONS0RaJg>vc5F#Ab>sbjrghnrP!SIzm%t~)-t?D7d|wD7nv zc}Mm42Kc;67b5*?D>O0FG&Tz<9l|CzaB0)ZOUKXp1brNO%eIu>a zr*FwsyB7O*-?znTNP#AjfTPGg-Flf>GgzXkGIV+E?*l~02ESFA-4eZN>U4LyXo2FrhuV7 z07icU73^my`KDMlYWsb~O_x~jO|ilU+55J<-+t0wySt#}aJUMe)83(lSRxVa{|7Dp zOzuCy!VwcFXJO(9(7oeisRHialBojjes!w??utBBz%PtZ1?XV{#bM3}b`}>VPyuRU z0TF|_0(9@{I4(XeHZU)MmlMRs%cqC=7c}_?%}Wx&;KBroK^&oX-vB^qsI?2!$-)@I zs$^tsVegFKfT=kfIsKx9-AxgM34CFTAaPmPngM`X7Pg|cju!t<@mEKGVSxRyvj6Nz z3}$WeKYIDc8^r(U<$rVVC-@?$a1JnIHK-Fn7l>fa0f6dISHyP7*dSnSd zWPa_YpdjLlDVGV@1j=Q~0pS60^YKBToJL?1UJeisACwzx!j8C_8gqic+yKxo5*=a$ zJA{Xuhn>p=#0zETnjn5bh4*-*tS5t^T_X%+2?Yv0xr< zL{k6#J}z$Ff9en+@$dKXfWU}s|NB@T5F#%AO~-q;s(;sU|8vfKJfMI0;Nt}&lHl(? z`1rtxQ2m<@#17{D_w|Ah5%?E-P7V;n(KsL&rifg$aEIRQJx~<}L$FKlP8&flm9aI2 g0YJZA>)#!gqZ7oz>DM`d5NW`LNl*VmNfPt_0M!lqR{#J2 literal 0 HcmV?d00001 diff --git a/internal/iac/terraform.go b/internal/iac/terraform.go new file mode 100644 index 0000000..6dc4dd9 --- /dev/null +++ b/internal/iac/terraform.go @@ -0,0 +1,150 @@ +// Package iac parses infrastructure-as-code artifacts (Terraform plans today, +// other tools later) into a uniform shape that downstream cost-impact analysis +// can consume. +// +// This package owns *parsing and structural validation only*. Per-resource +// attribute extraction (instance_type for aws_instance, allocated_storage for +// aws_db_instance, etc.) is intentionally out of scope here — it lives in a +// later milestone so the parser doesn't have to be touched whenever a new +// resource type is supported. +package iac + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" +) + +// Plan represents a parsed Terraform plan, i.e. the JSON document produced by +// `terraform show -json plan.tfplan`. +// +// We intentionally model only the fields downstream code reads. Real plans +// also include `prior_state`, `configuration`, `planned_values`, etc.; +// encoding/json silently ignores unknown fields, which is what we want for +// forward compatibility with future Terraform versions. +type Plan struct { + FormatVersion string `json:"format_version"` + TerraformVersion string `json:"terraform_version"` + ResourceChanges []ResourceChange `json:"resource_changes"` +} + +// Action is the canonical action type for a resource change. +// +// Note: ActionReplace is a *synthetic* action — Terraform itself reports +// replacements as a two-element actions slice, usually ["delete","create"] +// (the default lifecycle) or ["create","delete"] (when create_before_destroy +// is set). We collapse both into ActionReplace so downstream code (cost +// diffs, PR comments) can pattern-match on a single value. +type Action string + +// Canonical action values. The string forms match Terraform's wire format +// 1:1 except for "replace", which Terraform doesn't emit directly. +const ( + ActionNoop Action = "no-op" + ActionCreate Action = "create" + ActionRead Action = "read" + ActionUpdate Action = "update" + ActionDelete Action = "delete" + ActionReplace Action = "replace" +) + +// ResourceChange describes a single resource's planned change in a Terraform +// plan. One ResourceChange per address — there is exactly one entry per +// resource regardless of how many attributes change. +type ResourceChange struct { + Address string `json:"address"` + Mode string `json:"mode"` // "managed" or "data" + Type string `json:"type"` // e.g. "aws_instance" + Name string `json:"name"` + ProviderName string `json:"provider_name"` + Change Change `json:"change"` +} + +// Change holds the before/after state of a resource and the actions +// Terraform plans to take. Before is nil for a create; After is nil for +// a delete; both are populated for update and replace. +// +// Before and After are intentionally kept as map[string]interface{} rather +// than strongly-typed structs because the shape depends on the resource +// type (aws_instance vs aws_db_instance vs google_compute_instance, etc.). +// Per-type attribute extraction is the job of the next milestone — keeping +// the raw map here means new resource types can be supported later without +// touching this parser. +type Change struct { + Actions []string `json:"actions"` + Before map[string]interface{} `json:"before"` + After map[string]interface{} `json:"after"` +} + +// Action returns the canonical Action for this resource change. +// +// Replacement detection: Terraform emits a two-element actions slice for +// resource replacements — ["delete","create"] under the default lifecycle, +// ["create","delete"] when create_before_destroy is set. Either order is +// reported as ActionReplace. +// +// For unknown action strings (e.g. a hypothetical future "import"), the +// raw value is returned as Action("…") rather than swallowed. Callers can +// compare against the known constants and treat anything else as unknown. +func (rc ResourceChange) Action() Action { + a := rc.Change.Actions + if len(a) == 2 { + if (a[0] == "delete" && a[1] == "create") || + (a[0] == "create" && a[1] == "delete") { + return ActionReplace + } + } + if len(a) == 0 { + // Defensive: Terraform always emits at least one action, but a + // hand-crafted or truncated plan might not. Treat empty as no-op. + return ActionNoop + } + return Action(a[0]) +} + +// IsManaged reports whether this is a managed resource (operator-controlled +// infrastructure) as opposed to a data source. Data sources are read-only +// lookups and never have a cost impact, so cost-diff code skips them. +func (rc ResourceChange) IsManaged() bool { + return rc.Mode == "managed" +} + +// ParsePlan parses a Terraform plan JSON from r. +// +// Returns an error if: +// - the JSON is malformed +// - the document is missing format_version (likely the wrong file: +// a terraform.tfstate, a non-JSON `terraform show`, or unrelated JSON) +// +// resource_changes may be absent or null; both forms are accepted as a +// valid empty plan and surface as a Plan with nil ResourceChanges. +func ParsePlan(r io.Reader) (*Plan, error) { + var p Plan + if err := json.NewDecoder(r).Decode(&p); err != nil { + return nil, fmt.Errorf("decoding terraform plan JSON: %w", err) + } + // format_version is the cheapest sanity check that this is actually a + // `terraform show -json` document. Without it we'd silently accept + // arbitrary JSON and surface confusing errors much later. + if p.FormatVersion == "" { + return nil, errors.New( + "terraform plan: missing required field format_version " + + "(was the file produced by `terraform show -json`?)", + ) + } + return &p, nil +} + +// ParsePlanFile is a convenience wrapper around ParsePlan that opens the +// file at path and reads from it. Errors from os.Open are wrapped so +// callers can use errors.Is(err, os.ErrNotExist) to detect missing files. +func ParsePlanFile(path string) (*Plan, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening terraform plan file %q: %w", path, err) + } + defer f.Close() + return ParsePlan(f) +} diff --git a/internal/iac/terraform_test.go b/internal/iac/terraform_test.go new file mode 100644 index 0000000..743927f --- /dev/null +++ b/internal/iac/terraform_test.go @@ -0,0 +1,251 @@ +package iac + +import ( + "errors" + "os" + "strings" + "testing" +) + +func TestParsePlan_SimpleCreate(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_simple_create.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + + if p.FormatVersion != "1.2" { + t.Errorf("FormatVersion = %q, want 1.2", p.FormatVersion) + } + if p.TerraformVersion != "1.6.0" { + t.Errorf("TerraformVersion = %q, want 1.6.0", p.TerraformVersion) + } + if len(p.ResourceChanges) != 1 { + t.Fatalf("len(ResourceChanges) = %d, want 1", len(p.ResourceChanges)) + } + + rc := p.ResourceChanges[0] + if rc.Address != "aws_instance.web" { + t.Errorf("Address = %q, want aws_instance.web", rc.Address) + } + if rc.Type != "aws_instance" { + t.Errorf("Type = %q, want aws_instance", rc.Type) + } + if rc.Action() != ActionCreate { + t.Errorf("Action() = %q, want %q", rc.Action(), ActionCreate) + } + if !rc.IsManaged() { + t.Error("IsManaged() = false, want true") + } + if rc.Change.Before != nil { + t.Errorf("Before = %v, want nil for create", rc.Change.Before) + } + if got := rc.Change.After["instance_type"]; got != "t3.large" { + t.Errorf("After[instance_type] = %v, want t3.large", got) + } +} + +// TestParsePlan_Mixed verifies that a plan with multiple kinds of changes +// produces the right Action for each, including the data-source read which +// IsManaged() must filter out. +func TestParsePlan_Mixed(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_mixed.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + + got := map[string]Action{} + managed := map[string]bool{} + for _, rc := range p.ResourceChanges { + got[rc.Address] = rc.Action() + managed[rc.Address] = rc.IsManaged() + } + + wantActions := map[string]Action{ + "aws_instance.api": ActionCreate, + "aws_ebs_volume.cache": ActionDelete, + "aws_db_instance.main": ActionUpdate, + "data.aws_ami.ubuntu": ActionRead, + } + for addr, want := range wantActions { + if got[addr] != want { + t.Errorf("Action(%s) = %q, want %q", addr, got[addr], want) + } + } + + wantManaged := map[string]bool{ + "aws_instance.api": true, + "aws_ebs_volume.cache": true, + "aws_db_instance.main": true, + "data.aws_ami.ubuntu": false, + } + for addr, want := range wantManaged { + if managed[addr] != want { + t.Errorf("IsManaged(%s) = %v, want %v", addr, managed[addr], want) + } + } +} + +func TestParsePlan_Replace(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_replace.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if got := p.ResourceChanges[0].Action(); got != ActionReplace { + t.Errorf("Action() = %q, want %q (delete+create should collapse to replace)", + got, ActionReplace) + } +} + +// TestParsePlan_ReplaceReversed verifies that ["create","delete"] (the +// create_before_destroy lifecycle order) also collapses to ActionReplace, +// not just the default ["delete","create"] order. +func TestParsePlan_ReplaceReversed(t *testing.T) { + in := `{ + "format_version": "1.2", + "resource_changes": [{ + "address": "aws_instance.x", + "mode": "managed", "type": "aws_instance", "name": "x", + "change": { "actions": ["create","delete"], "before": null, "after": null } + }] + }` + p, err := ParsePlan(strings.NewReader(in)) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if got := p.ResourceChanges[0].Action(); got != ActionReplace { + t.Errorf("Action() = %q, want %q", got, ActionReplace) + } +} + +// TestParsePlan_EmptyPlan covers all three forms Terraform may use for a +// plan with no changes: explicit empty array, explicit null, and the field +// omitted entirely. None of them should be rejected. +func TestParsePlan_EmptyPlan(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_empty.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if len(p.ResourceChanges) != 0 { + t.Errorf("len(ResourceChanges) = %d, want 0", len(p.ResourceChanges)) + } + + cases := []struct { + name string + json string + }{ + {"null array", `{"format_version": "1.2", "resource_changes": null}`}, + {"omitted field", `{"format_version": "1.2"}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plan, err := ParsePlan(strings.NewReader(c.json)) + if err != nil { + t.Fatalf("ParsePlan: %v", err) + } + if len(plan.ResourceChanges) != 0 { + t.Errorf("len(ResourceChanges) = %d, want 0", len(plan.ResourceChanges)) + } + }) + } +} + +func TestParsePlan_InvalidJSON(t *testing.T) { + cases := []struct { + name string + in string + }{ + {"garbage", "not json at all"}, + {"unterminated object", "{"}, + {"empty input", ""}, + {"truncated mid-string", `{"format_version": "1.`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ParsePlan(strings.NewReader(c.in)) + if err == nil { + t.Errorf("expected error for %q, got nil", c.in) + return + } + if !strings.Contains(err.Error(), "decoding terraform plan JSON") { + t.Errorf("error should mention decoding context: %v", err) + } + }) + } +} + +func TestParsePlan_MissingFormatVersion(t *testing.T) { + in := `{"terraform_version": "1.6.0", "resource_changes": []}` + _, err := ParsePlan(strings.NewReader(in)) + if err == nil { + t.Fatal("expected error for missing format_version, got nil") + } + if !strings.Contains(err.Error(), "format_version") { + t.Errorf("error should mention format_version: %v", err) + } +} + +func TestIsManaged(t *testing.T) { + cases := []struct { + mode string + want bool + }{ + {"managed", true}, + {"data", false}, + {"", false}, + {"unknown", false}, + } + for _, c := range cases { + got := ResourceChange{Mode: c.mode}.IsManaged() + if got != c.want { + t.Errorf("Mode=%q: IsManaged() = %v, want %v", c.mode, got, c.want) + } + } +} + +// TestAction_EdgeCases covers Action() branches not exercised by the +// fixtures: empty actions slice (defensive no-op fallback), unknown action +// strings (passed through), and a non-replace two-element slice. +func TestAction_EdgeCases(t *testing.T) { + cases := []struct { + name string + actions []string + want Action + }{ + {"empty", []string{}, ActionNoop}, + {"nil", nil, ActionNoop}, + {"unknown action passes through", []string{"import"}, Action("import")}, + {"two non-replace actions does not collapse", []string{"create", "update"}, ActionCreate}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rc := ResourceChange{Change: Change{Actions: c.actions}} + if got := rc.Action(); got != c.want { + t.Errorf("Action() = %q, want %q", got, c.want) + } + }) + } +} + +func TestParsePlanFile(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + p, err := ParsePlanFile("testdata/plan_simple_create.json") + if err != nil { + t.Fatalf("ParsePlanFile: %v", err) + } + if p == nil { + t.Fatal("got nil plan") + } + }) + + t.Run("file not found", func(t *testing.T) { + _, err := ParsePlanFile("testdata/this-does-not-exist.json") + if err == nil { + t.Fatal("expected error opening missing file") + } + // The error chain must preserve os.ErrNotExist so callers can + // distinguish "missing file" from other failure modes. + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error should wrap os.ErrNotExist: %v", err) + } + }) +} diff --git a/internal/iac/testdata/plan_empty.json b/internal/iac/testdata/plan_empty.json new file mode 100644 index 0000000..cc412b2 --- /dev/null +++ b/internal/iac/testdata/plan_empty.json @@ -0,0 +1,5 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [] +} diff --git a/internal/iac/testdata/plan_mixed.json b/internal/iac/testdata/plan_mixed.json new file mode 100644 index 0000000..5df4b38 --- /dev/null +++ b/internal/iac/testdata/plan_mixed.json @@ -0,0 +1,72 @@ +{ + "format_version": "1.2", + "terraform_version": "1.7.4", + "resource_changes": [ + { + "address": "aws_instance.api", + "mode": "managed", + "type": "aws_instance", + "name": "api", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "m5.large", + "ami": "ami-9876543210abcdef0", + "availability_zone": "us-east-2a" + } + } + }, + { + "address": "aws_ebs_volume.cache", + "mode": "managed", + "type": "aws_ebs_volume", + "name": "cache", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["delete"], + "before": { + "size": 500, + "type": "gp3", + "availability_zone": "us-east-2a" + }, + "after": null + } + }, + { + "address": "aws_db_instance.main", + "mode": "managed", + "type": "aws_db_instance", + "name": "main", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["update"], + "before": { + "instance_class": "db.t3.medium", + "engine": "postgres", + "allocated_storage": 100 + }, + "after": { + "instance_class": "db.r5.large", + "engine": "postgres", + "allocated_storage": 100 + } + } + }, + { + "address": "data.aws_ami.ubuntu", + "mode": "data", + "type": "aws_ami", + "name": "ubuntu", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["read"], + "before": null, + "after": { + "id": "ami-deadbeef00000000" + } + } + } + ] +} diff --git a/internal/iac/testdata/plan_replace.json b/internal/iac/testdata/plan_replace.json new file mode 100644 index 0000000..5783931 --- /dev/null +++ b/internal/iac/testdata/plan_replace.json @@ -0,0 +1,24 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [ + { + "address": "aws_instance.legacy", + "mode": "managed", + "type": "aws_instance", + "name": "legacy", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["delete", "create"], + "before": { + "instance_type": "t3.medium", + "ami": "ami-0000000000000aaaa" + }, + "after": { + "instance_type": "m5.xlarge", + "ami": "ami-1111111111111bbbb" + } + } + } + ] +} diff --git a/internal/iac/testdata/plan_simple_create.json b/internal/iac/testdata/plan_simple_create.json new file mode 100644 index 0000000..5d6df6b --- /dev/null +++ b/internal/iac/testdata/plan_simple_create.json @@ -0,0 +1,22 @@ +{ + "format_version": "1.2", + "terraform_version": "1.6.0", + "resource_changes": [ + { + "address": "aws_instance.web", + "mode": "managed", + "type": "aws_instance", + "name": "web", + "provider_name": "registry.terraform.io/hashicorp/aws", + "change": { + "actions": ["create"], + "before": null, + "after": { + "instance_type": "t3.large", + "ami": "ami-0abcd1234efgh5678", + "availability_zone": "us-east-1a" + } + } + } + ] +} From 9c9a46ced87e803e446db8a8863e4dfd59f3f13d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Thu, 7 May 2026 16:43:13 -0400 Subject: [PATCH 02/21] feat: implement AWS resource attribute extraction for EC2, RDS, and EBS --- internal/iac/aws/aws.go | 73 +++++++++++++ internal/iac/aws/aws_test.go | 149 +++++++++++++++++++++++++ internal/iac/aws/ebs.go | 93 ++++++++++++++++ internal/iac/aws/ebs_test.go | 174 +++++++++++++++++++++++++++++ internal/iac/aws/ec2.go | 106 ++++++++++++++++++ internal/iac/aws/ec2_test.go | 177 ++++++++++++++++++++++++++++++ internal/iac/aws/helpers.go | 125 +++++++++++++++++++++ internal/iac/aws/helpers_test.go | 178 ++++++++++++++++++++++++++++++ internal/iac/aws/rds.go | 111 +++++++++++++++++++ internal/iac/aws/rds_test.go | 181 +++++++++++++++++++++++++++++++ 10 files changed, 1367 insertions(+) create mode 100644 internal/iac/aws/aws.go create mode 100644 internal/iac/aws/aws_test.go create mode 100644 internal/iac/aws/ebs.go create mode 100644 internal/iac/aws/ebs_test.go create mode 100644 internal/iac/aws/ec2.go create mode 100644 internal/iac/aws/ec2_test.go create mode 100644 internal/iac/aws/helpers.go create mode 100644 internal/iac/aws/helpers_test.go create mode 100644 internal/iac/aws/rds.go create mode 100644 internal/iac/aws/rds_test.go diff --git a/internal/iac/aws/aws.go b/internal/iac/aws/aws.go new file mode 100644 index 0000000..4b30d08 --- /dev/null +++ b/internal/iac/aws/aws.go @@ -0,0 +1,73 @@ +// Package aws extracts strongly-typed cost-impacting attributes from +// Terraform plan resource changes for AWS resources. +// +// Each ExtractXxx function takes a map[string]interface{} (the shape of +// terraform-iac.Change.Before or .After) rather than the full ResourceChange +// from the parser package. That separation is deliberate: extractors don't +// need to know about actions, addresses, or before/after distinctions — +// the diff engine in the next milestone decides which state to extract. +// +// Currently supported: aws_instance, aws_db_instance, aws_ebs_volume. +// Other types (Lambda, NAT, RDS Cluster, EKS, ElastiCache, S3) are added +// in subsequent milestones. +package aws + +// ResourceAttributes is a discriminated union over the AWS resource types +// this package supports. Exactly one of EC2, RDS, EBS is non-nil; the +// Type field identifies which. +// +// We use this shape rather than a bare interface{} for two reasons: +// +// 1. Type safety at call sites — pricing code can write `if r.EC2 != nil` +// and get nil-deref protection from the compiler. +// 2. Exhaustive switches feasible — adding a new type forces every +// consumer to add a case (or explicitly default), instead of silently +// dispatching a runtime type assertion that no-ops on the new type. +type ResourceAttributes struct { + Type string + EC2 *EC2Attributes + RDS *RDSAttributes + EBS *EBSAttributes +} + +// Extract dispatches to the type-specific extractor for resourceType. +// +// Unsupported resource types return (nil, nil) — the caller treats +// "no data" as "no cost impact for this resource". This is intentional: +// a real Terraform plan contains many resources we don't price (IAM roles, +// VPCs, Route53 records, etc.). Forcing every caller to distinguish +// "unsupported" from "extraction failed" would push complexity outward +// instead of containing it here. +// +// Extraction failures on supported types still return (nil, error). +func Extract(resourceType string, attrs map[string]interface{}) (*ResourceAttributes, error) { + switch resourceType { + case "aws_instance": + ec2, err := ExtractEC2(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, EC2: ec2}, nil + case "aws_db_instance": + rds, err := ExtractRDS(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, RDS: rds}, nil + case "aws_ebs_volume": + ebs, err := ExtractEBS(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, EBS: ebs}, nil + } + return nil, nil +} + +// SupportedTypes returns the AWS resource types this package can extract +// attributes for. The returned slice is freshly allocated on each call so +// callers can mutate it without affecting future returns. Order is stable +// across calls so it can drive menus or docs without sorting. +func SupportedTypes() []string { + return []string{"aws_instance", "aws_db_instance", "aws_ebs_volume"} +} diff --git a/internal/iac/aws/aws_test.go b/internal/iac/aws/aws_test.go new file mode 100644 index 0000000..2939d80 --- /dev/null +++ b/internal/iac/aws/aws_test.go @@ -0,0 +1,149 @@ +package aws + +import ( + "fmt" + "reflect" + "sort" + "testing" +) + +func TestExtract_DispatchesEC2(t *testing.T) { + r, err := Extract("aws_instance", map[string]interface{}{ + "instance_type": "t3.large", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_instance" { + t.Errorf("Type = %q", r.Type) + } + if r.EC2 == nil { + t.Fatal("EC2 is nil — dispatch failed") + } + if r.EC2.InstanceType != "t3.large" { + t.Errorf("InstanceType = %q", r.EC2.InstanceType) + } + if r.RDS != nil || r.EBS != nil { + t.Errorf("non-EC2 fields should be nil: RDS=%v EBS=%v", r.RDS, r.EBS) + } +} + +func TestExtract_DispatchesRDS(t *testing.T) { + r, err := Extract("aws_db_instance", map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.micro", + "allocated_storage": float64(20), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.RDS == nil || r.RDS.Engine != "postgres" { + t.Errorf("RDS not populated: %+v", r) + } + if r.EC2 != nil || r.EBS != nil { + t.Errorf("non-RDS fields should be nil") + } +} + +func TestExtract_DispatchesEBS(t *testing.T) { + r, err := Extract("aws_ebs_volume", map[string]interface{}{ + "type": "gp3", + "size": float64(100), + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.EBS == nil || r.EBS.Type != "gp3" { + t.Errorf("EBS not populated: %+v", r) + } + if r.EC2 != nil || r.RDS != nil { + t.Errorf("non-EBS fields should be nil") + } +} + +// TestExtract_UnsupportedType verifies the contract documented in the +// dispatcher comment: unknown types are NOT errors, they are silently +// reported as "no data" so the caller can skip them. +func TestExtract_UnsupportedType(t *testing.T) { + r, err := Extract("aws_iam_role", map[string]interface{}{ + "name": "my-role", + }) + if err != nil { + t.Errorf("err = %v, want nil for unsupported type", err) + } + if r != nil { + t.Errorf("r = %+v, want nil for unsupported type", r) + } +} + +// TestExtract_PropagatesUnderlyingErrors verifies that when the type IS +// supported but extraction fails, the error from the inner extractor +// surfaces unchanged through Extract. +func TestExtract_PropagatesUnderlyingErrors(t *testing.T) { + cases := []struct { + name string + typ string + attrs map[string]interface{} + want string + }{ + { + "EC2 nil attrs", + "aws_instance", nil, + "aws_instance: empty attributes", + }, + { + "RDS missing required", + "aws_db_instance", + map[string]interface{}{"instance_class": "db.t3.micro"}, + `aws_db_instance: missing required attribute "engine"`, + }, + { + "EBS empty attrs", + "aws_ebs_volume", map[string]interface{}{}, + "aws_ebs_volume: empty attributes", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := Extract(c.typ, c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestSupportedTypes(t *testing.T) { + got := SupportedTypes() + // Sort defensively — the contract is "stable order across calls", + // but the test asserts membership, not a particular order. + sortedGot := append([]string(nil), got...) + sort.Strings(sortedGot) + want := []string{"aws_db_instance", "aws_ebs_volume", "aws_instance"} + if !reflect.DeepEqual(sortedGot, want) { + t.Errorf("got %v, want %v", sortedGot, want) + } + + // Mutating the returned slice must not affect future calls — the + // docstring promises a fresh allocation each call. + got[0] = "MUTATED" + again := SupportedTypes() + if again[0] == "MUTATED" { + t.Error("SupportedTypes() shares state across calls — should return a fresh slice") + } +} + +func ExampleExtract() { + attrs := map[string]interface{}{ + "instance_type": "m5.large", + } + r, err := Extract("aws_instance", attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s -> %s\n", r.Type, r.EC2.InstanceType) + // Output: aws_instance -> m5.large +} diff --git a/internal/iac/aws/ebs.go b/internal/iac/aws/ebs.go new file mode 100644 index 0000000..558d7ec --- /dev/null +++ b/internal/iac/aws/ebs.go @@ -0,0 +1,93 @@ +package aws + +// EBSAttributes captures the cost-impacting fields of an aws_ebs_volume +// resource (standalone volumes — root volumes attached to EC2 instances +// are tracked under EC2Attributes.RootBlock*). +type EBSAttributes struct { + // Type is the volume type: gp2, gp3, io1, io2, st1, sc1, or "standard" + // for the legacy magnetic volumes. Required. Cross-attribute validation + // (e.g. "io1 must have iops") is intentionally deferred — that's a + // pricing-engine concern; here we just surface what Terraform declared. + Type string + + // Size is the volume size in GB. Required. + Size int + + // Iops is the provisioned IOPS. Optional — required by io1/io2 and + // allowed for gp3, but we don't enforce that here. + Iops int + + // Throughput is the provisioned throughput in MB/s. Optional, only + // meaningful for gp3. + Throughput int + + // AvailabilityZone is the AZ the volume lives in. Optional — pricing + // for EBS is regional, not AZ-specific, but the attribute is included + // so callers can match the volume to its instance's AZ if needed. + AvailabilityZone string + + // Encrypted indicates whether the volume is encrypted at rest. + // Defaults to false. Encryption itself is free; the attribute is + // captured because some pricing analyses do compliance scoring. + Encrypted bool +} + +// ExtractEBS reads cost-impacting attributes from an aws_ebs_volume +// attribute map. +// +// Required: type, size. Defaults: iops=0, throughput=0, encrypted=false. +// +// We don't validate cross-attribute consistency (gp3 with/without +// throughput, io1 with/without iops) — that's the pricing engine's job. +// Here we report exactly what Terraform declared. +func ExtractEBS(attrs map[string]interface{}) (*EBSAttributes, error) { + const typ = "aws_ebs_volume" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + volType, present, err := getString(attrs, "type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "type") + } + + size, present, err := getInt(attrs, "size") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "size") + } + + iops, _, err := getInt(attrs, "iops") + if err != nil { + return nil, wrapAttr(typ, err) + } + + throughput, _, err := getInt(attrs, "throughput") + if err != nil { + return nil, wrapAttr(typ, err) + } + + az, _, err := getString(attrs, "availability_zone") + if err != nil { + return nil, wrapAttr(typ, err) + } + + encrypted, _, err := getBool(attrs, "encrypted") + if err != nil { + return nil, wrapAttr(typ, err) + } + + return &EBSAttributes{ + Type: volType, + Size: size, + Iops: iops, + Throughput: throughput, + AvailabilityZone: az, + Encrypted: encrypted, + }, nil +} diff --git a/internal/iac/aws/ebs_test.go b/internal/iac/aws/ebs_test.go new file mode 100644 index 0000000..0855af8 --- /dev/null +++ b/internal/iac/aws/ebs_test.go @@ -0,0 +1,174 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractEBS_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "type": "gp3", + "size": float64(500), + "iops": float64(3000), + "throughput": float64(125), + "availability_zone": "us-east-1a", + "encrypted": true, + "some_extra_field": "ignored", + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("ExtractEBS: %v", err) + } + want := EBSAttributes{ + Type: "gp3", + Size: 500, + Iops: 3000, + Throughput: 125, + AvailabilityZone: "us-east-1a", + Encrypted: true, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractEBS_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "type": "standard", + "size": float64(100), + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("ExtractEBS: %v", err) + } + if got.Type != "standard" || got.Size != 100 { + t.Errorf("required fields wrong: %+v", got) + } + // Optional defaults. + if got.Iops != 0 || got.Throughput != 0 { + t.Errorf("Iops/Throughput should default to 0: %+v", got) + } + if got.Encrypted { + t.Error("Encrypted should default to false") + } +} + +// TestExtractEBS_NoCrossAttrValidation confirms the explicit non-feature: +// we do NOT reject io1 without iops here. That validation lives in the +// pricing engine. +func TestExtractEBS_NoCrossAttrValidation(t *testing.T) { + attrs := map[string]interface{}{ + "type": "io1", + "size": float64(100), + // iops absent — pricing might fail later, but the extractor must not. + } + got, err := ExtractEBS(attrs) + if err != nil { + t.Fatalf("io1 without iops should NOT error here: %v", err) + } + if got.Iops != 0 { + t.Errorf("Iops = %d, want 0", got.Iops) + } +} + +func TestExtractEBS_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no type", + map[string]interface{}{"size": float64(100)}, + `aws_ebs_volume: missing required attribute "type"`, + }, + { + "no size", + map[string]interface{}{"type": "gp3"}, + `aws_ebs_volume: missing required attribute "size"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEBS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractEBS_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"type as int", map[string]interface{}{"type": 42, "size": float64(10)}, "type"}, + {"size as string", map[string]interface{}{"type": "gp3", "size": "100"}, "size"}, + {"throughput fractional", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "throughput": 1.5, + }, "throughput"}, + {"encrypted as int", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "encrypted": 1, + }, "encrypted"}, + {"iops fractional", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "iops": 1.5, + }, "iops"}, + {"availability_zone as int", map[string]interface{}{ + "type": "gp3", + "size": float64(10), + "availability_zone": 1, + }, "availability_zone"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEBS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_ebs_volume:") { + t.Errorf("error should start with 'aws_ebs_volume:': %v", err) + } + }) + } +} + +func TestExtractEBS_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractEBS(attrs) + if err == nil { + t.Fatal("expected error for empty/nil attrs") + } + if err.Error() != "aws_ebs_volume: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractEBS() { + attrs := map[string]interface{}{ + "type": "gp3", + "size": float64(500), + "throughput": float64(250), + } + out, err := ExtractEBS(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s %dGB throughput=%dMB/s\n", out.Type, out.Size, out.Throughput) + // Output: gp3 500GB throughput=250MB/s +} diff --git a/internal/iac/aws/ec2.go b/internal/iac/aws/ec2.go new file mode 100644 index 0000000..13ad6f8 --- /dev/null +++ b/internal/iac/aws/ec2.go @@ -0,0 +1,106 @@ +package aws + +// EC2Attributes captures the cost-impacting fields of an aws_instance +// resource. We deliberately exclude attributes that don't affect price +// (tags, security_groups, key_name, etc.) so the struct stays focused on +// what the pricing engine cares about. +type EC2Attributes struct { + // InstanceType is the EC2 instance class, e.g. "t3.large", "m5.xlarge". + // Required. + InstanceType string + + // AvailabilityZone is the AZ the instance is launched into, e.g. + // "us-east-1a". Optional — when missing, region-level pricing applies. + AvailabilityZone string + + // Tenancy controls whether the instance shares hardware. Defaults to + // "default"; other valid values are "dedicated" and "host" — both have + // significant pricing implications. + Tenancy string + + // EBSOptimized indicates whether the instance has dedicated EBS + // throughput. Defaults to false. Some instance families have it + // enabled implicitly, but we track only the explicit attribute here. + EBSOptimized bool + + // RootBlockSize is the size of the root volume in GB, drawn from the + // first root_block_device entry. Zero when the block isn't specified + // (in which case AWS applies the AMI default). + RootBlockSize int + + // RootBlockType is the volume type of the root device (e.g. "gp3"). + // Empty when the block isn't specified. + RootBlockType string +} + +// ExtractEC2 reads cost-impacting attributes from an aws_instance attribute +// map (typically resource_changes[].change.after for a create, or +// resource_changes[].change.before for a delete). +// +// Required: instance_type. Defaults: tenancy="default", ebs_optimized=false. +// +// Unknown attributes are ignored. Terraform adds and renames fields between +// versions, so a strict allow-list would force a parser update on every +// minor Terraform release. +func ExtractEC2(attrs map[string]interface{}) (*EC2Attributes, error) { + const typ = "aws_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + instanceType, present, err := getString(attrs, "instance_type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "instance_type") + } + + az, _, err := getString(attrs, "availability_zone") + if err != nil { + return nil, wrapAttr(typ, err) + } + + tenancy, _, err := getString(attrs, "tenancy") + if err != nil { + return nil, wrapAttr(typ, err) + } + if tenancy == "" { + tenancy = "default" + } + + ebsOpt, _, err := getBool(attrs, "ebs_optimized") + if err != nil { + return nil, wrapAttr(typ, err) + } + + out := &EC2Attributes{ + InstanceType: instanceType, + AvailabilityZone: az, + Tenancy: tenancy, + EBSOptimized: ebsOpt, + } + + // root_block_device is an HCL block but the JSON plan renders it as a + // list, even though only one entry is allowed. Pull the first entry + // when present and read its size and type. + rbd, present, err := getNestedFirst(attrs, "root_block_device") + if err != nil { + return nil, wrapAttr(typ, err) + } + if present { + size, _, err := getInt(rbd, "volume_size") + if err != nil { + return nil, wrapAttr(typ+".root_block_device", err) + } + out.RootBlockSize = size + + volType, _, err := getString(rbd, "volume_type") + if err != nil { + return nil, wrapAttr(typ+".root_block_device", err) + } + out.RootBlockType = volType + } + + return out, nil +} diff --git a/internal/iac/aws/ec2_test.go b/internal/iac/aws/ec2_test.go new file mode 100644 index 0000000..a6839c0 --- /dev/null +++ b/internal/iac/aws/ec2_test.go @@ -0,0 +1,177 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractEC2_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "instance_type": "t3.large", + "availability_zone": "us-east-1a", + "tenancy": "dedicated", + "ebs_optimized": true, + "root_block_device": []interface{}{ + map[string]interface{}{ + "volume_size": float64(100), + "volume_type": "gp3", + }, + }, + // Unknown attribute — must be ignored, not error. + "some_future_field": "future-value", + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("ExtractEC2: %v", err) + } + want := EC2Attributes{ + InstanceType: "t3.large", + AvailabilityZone: "us-east-1a", + Tenancy: "dedicated", + EBSOptimized: true, + RootBlockSize: 100, + RootBlockType: "gp3", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractEC2_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "instance_type": "m5.xlarge", + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("ExtractEC2: %v", err) + } + if got.InstanceType != "m5.xlarge" { + t.Errorf("InstanceType = %q", got.InstanceType) + } + // Defaults must apply when optional fields are absent. + if got.Tenancy != "default" { + t.Errorf("Tenancy = %q, want %q (default)", got.Tenancy, "default") + } + if got.EBSOptimized { + t.Error("EBSOptimized = true, want false (default)") + } + if got.AvailabilityZone != "" { + t.Errorf("AvailabilityZone = %q, want empty", got.AvailabilityZone) + } + if got.RootBlockSize != 0 || got.RootBlockType != "" { + t.Errorf("root block fields should be zero when block is absent: %+v", got) + } +} + +func TestExtractEC2_MissingInstanceType(t *testing.T) { + _, err := ExtractEC2(map[string]interface{}{ + "availability_zone": "us-east-1a", + }) + if err == nil { + t.Fatal("expected error for missing instance_type") + } + want := `aws_instance: missing required attribute "instance_type"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractEC2_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string // substring expected in error + }{ + {"instance_type as int", map[string]interface{}{"instance_type": 42}, "instance_type"}, + {"availability_zone as bool", map[string]interface{}{ + "instance_type": "t3.micro", + "availability_zone": true, + }, "availability_zone"}, + {"ebs_optimized as string", map[string]interface{}{ + "instance_type": "t3.micro", + "ebs_optimized": "yes", + }, "ebs_optimized"}, + {"root_block_device as string", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": "not-a-list", + }, "root_block_device"}, + {"root_block_device[0] not a map", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{"not-a-map"}, + }, "root_block_device"}, + {"tenancy as int", map[string]interface{}{ + "instance_type": "t3.micro", + "tenancy": 42, + }, "tenancy"}, + {"root_block_device.volume_size as string", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{ + map[string]interface{}{"volume_size": "100"}, + }, + }, "volume_size"}, + {"root_block_device.volume_type as bool", map[string]interface{}{ + "instance_type": "t3.micro", + "root_block_device": []interface{}{ + map[string]interface{}{"volume_type": true}, + }, + }, "volume_type"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractEC2(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_instance") { + t.Errorf("error should start with 'aws_instance': %v", err) + } + }) + } +} + +func TestExtractEC2_RootBlockDeviceEmptyList(t *testing.T) { + // HCL allows declaring `root_block_device {}` zero or one times. + // When zero times, JSON renders as []. Must not panic, must not error, + // must leave RootBlock* zero. + attrs := map[string]interface{}{ + "instance_type": "t3.small", + "root_block_device": []interface{}{}, + } + got, err := ExtractEC2(attrs) + if err != nil { + t.Fatalf("empty root_block_device list: %v", err) + } + if got.RootBlockSize != 0 || got.RootBlockType != "" { + t.Errorf("expected zero root block fields, got size=%d type=%q", + got.RootBlockSize, got.RootBlockType) + } +} + +func TestExtractEC2_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractEC2(attrs) + if err == nil { + t.Fatalf("expected error for empty/nil attrs (got nil for %v)", attrs) + } + if err.Error() != "aws_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractEC2() { + attrs := map[string]interface{}{ + "instance_type": "t3.large", + "availability_zone": "us-east-1a", + } + out, err := ExtractEC2(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s in %s (tenancy=%s)\n", out.InstanceType, out.AvailabilityZone, out.Tenancy) + // Output: t3.large in us-east-1a (tenancy=default) +} diff --git a/internal/iac/aws/helpers.go b/internal/iac/aws/helpers.go new file mode 100644 index 0000000..cb23dc9 --- /dev/null +++ b/internal/iac/aws/helpers.go @@ -0,0 +1,125 @@ +package aws + +import ( + "fmt" + "math" +) + +// The helpers in this file are intentionally unexported. They exist to keep +// each extractor (ec2.go / rds.go / ebs.go) small and to centralize three +// concerns that would otherwise be scattered: +// +// 1. The (value, present, error) tri-state for optional attributes — it's +// not enough to return zero+error because callers often need to apply +// a default *only* when the attribute is missing, vs. when it errored. +// 2. The float64-as-int quirk introduced by encoding/json (every JSON +// number decodes to float64 by default). +// 3. Consistent error messages across resource types so the diff engine +// in the next milestone can match on them if it needs to. + +// getString returns the string at key. +// +// - (s, true, nil) — key is present and is a string. +// - ("", false, nil) — key is missing or its value is JSON null. +// - ("", false, err) — key is present but the value is the wrong type. +// +// JSON null is treated as "missing" rather than "wrong type" because +// Terraform plans encode unset string attributes as null (not empty string), +// and the calling extractor wants to apply its own default in that case. +func getString(attrs map[string]interface{}, key string) (string, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return "", false, nil + } + s, ok := raw.(string) + if !ok { + return "", false, fmt.Errorf("attribute %q: want string, got %T", key, raw) + } + return s, true, nil +} + +// getInt returns the int at key. +// +// JSON numbers unmarshal as float64 by default, so this accepts both +// float64 (whole-number values only — fractional input is an error) and +// int (in case the caller built the map programmatically). Anything else +// is a type mismatch. +func getInt(attrs map[string]interface{}, key string) (int, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return 0, false, nil + } + switch v := raw.(type) { + case int: + return v, true, nil + case float64: + // JSON numbers are float64. Reject anything with a fractional part — + // the caller asked for an integer and a value like 3.5 means the + // upstream data is inconsistent, not "round it for me". + if math.Trunc(v) != v { + return 0, false, fmt.Errorf("attribute %q: want integer, got fractional %g", key, v) + } + return int(v), true, nil + default: + return 0, false, fmt.Errorf("attribute %q: want integer, got %T", key, raw) + } +} + +// getBool returns the bool at key. Strict — JSON true/false only, never +// "true"/"false" strings or 0/1 integers. +func getBool(attrs map[string]interface{}, key string) (bool, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return false, false, nil + } + b, ok := raw.(bool) + if !ok { + return false, false, fmt.Errorf("attribute %q: want bool, got %T", key, raw) + } + return b, true, nil +} + +// getNestedFirst returns the first element of a list-of-maps attribute as +// a map. This is the shape Terraform plans use for nested blocks like +// root_block_device — even when HCL only allows one block, the JSON plan +// always wraps it in an array. +// +// Returns (nil, false, nil) when the key is absent, JSON null, or an empty +// list. Returns (nil, false, err) when the value isn't a list, or its +// first element isn't a map. +func getNestedFirst(attrs map[string]interface{}, key string) (map[string]interface{}, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return nil, false, nil + } + list, ok := raw.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("attribute %q: want list, got %T", key, raw) + } + if len(list) == 0 { + return nil, false, nil + } + first, ok := list[0].(map[string]interface{}) + if !ok { + return nil, false, fmt.Errorf("attribute %q[0]: want object, got %T", key, list[0]) + } + return first, true, nil +} + +// errEmptyAttrs is the canonical error every extractor returns when its +// input map is nil or empty. Centralized so the message stays uniform. +func errEmptyAttrs(typ string) error { + return fmt.Errorf("%s: empty attributes", typ) +} + +// errMissingRequired produces a uniform message for required attributes +// that are absent or null. +func errMissingRequired(typ, key string) error { + return fmt.Errorf("%s: missing required attribute %q", typ, key) +} + +// wrapAttr wraps a helper-level error with the resource-type prefix so +// every error in the package starts with `aws_: …`. +func wrapAttr(typ string, err error) error { + return fmt.Errorf("%s: %w", typ, err) +} diff --git a/internal/iac/aws/helpers_test.go b/internal/iac/aws/helpers_test.go new file mode 100644 index 0000000..dcacb5e --- /dev/null +++ b/internal/iac/aws/helpers_test.go @@ -0,0 +1,178 @@ +package aws + +import ( + "strings" + "testing" +) + +func TestGetString(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + key string + want string + wantPres bool + wantErr bool + }{ + {"happy", map[string]interface{}{"k": "value"}, "k", "value", true, false}, + {"missing", map[string]interface{}{"other": "x"}, "k", "", false, false}, + {"null value treated as missing", map[string]interface{}{"k": nil}, "k", "", false, false}, + {"empty string is present", map[string]interface{}{"k": ""}, "k", "", true, false}, + {"wrong type int", map[string]interface{}{"k": 42}, "k", "", false, true}, + {"wrong type bool", map[string]interface{}{"k": true}, "k", "", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, pres, err := getString(c.attrs, c.key) + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %q, want %q", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetInt(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want int + wantPres bool + wantErr bool + }{ + {"float64 whole number (JSON default)", map[string]interface{}{"k": float64(42)}, 42, true, false}, + {"int direct (programmatic map)", map[string]interface{}{"k": 7}, 7, true, false}, + {"zero is present", map[string]interface{}{"k": float64(0)}, 0, true, false}, + {"missing", map[string]interface{}{}, 0, false, false}, + {"null value treated as missing", map[string]interface{}{"k": nil}, 0, false, false}, + {"fractional float64 is error", map[string]interface{}{"k": 3.5}, 0, false, true}, + {"string is wrong type", map[string]interface{}{"k": "42"}, 0, false, true}, + {"bool is wrong type", map[string]interface{}{"k": true}, 0, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, pres, err := getInt(c.attrs, "k") + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %d, want %d", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetBool(t *testing.T) { + cases := []struct { + name string + val interface{} + want bool + wantPres bool + wantErr bool + }{ + {"true", true, true, true, false}, + {"false is present", false, false, true, false}, + {"missing", nil, false, false, false}, + {"string 'true' is wrong type", "true", false, false, true}, + {"int 1 is wrong type", 1, false, false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := map[string]interface{}{} + if c.val != nil || c.name == "missing" { + if c.name == "missing" { + // Leave the key absent. + } else { + attrs["k"] = c.val + } + } + got, pres, err := getBool(attrs, "k") + if (err != nil) != c.wantErr { + t.Fatalf("err = %v, wantErr = %v", err, c.wantErr) + } + if got != c.want { + t.Errorf("value = %v, want %v", got, c.want) + } + if pres != c.wantPres { + t.Errorf("present = %v, want %v", pres, c.wantPres) + } + }) + } +} + +func TestGetNestedFirst(t *testing.T) { + t.Run("happy path", func(t *testing.T) { + attrs := map[string]interface{}{ + "block": []interface{}{ + map[string]interface{}{"size": float64(100)}, + map[string]interface{}{"size": float64(200)}, + }, + } + got, pres, err := getNestedFirst(attrs, "block") + if err != nil || !pres { + t.Fatalf("err = %v, pres = %v", err, pres) + } + if got["size"] != float64(100) { + t.Errorf("first[size] = %v, want 100 (must take FIRST element)", got["size"]) + } + }) + + t.Run("missing key", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{}, "block") + if err != nil || pres { + t.Errorf("missing key: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("null value", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{"block": nil}, "block") + if err != nil || pres { + t.Errorf("null value: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("empty list", func(t *testing.T) { + _, pres, err := getNestedFirst(map[string]interface{}{"block": []interface{}{}}, "block") + if err != nil || pres { + t.Errorf("empty list: err=%v pres=%v, want nil/false", err, pres) + } + }) + + t.Run("not a list", func(t *testing.T) { + _, _, err := getNestedFirst(map[string]interface{}{"block": "not a list"}, "block") + if err == nil { + t.Fatal("expected error for string-typed value") + } + if !strings.Contains(err.Error(), "want list") { + t.Errorf("error should say 'want list': %v", err) + } + }) + + t.Run("first element not a map", func(t *testing.T) { + attrs := map[string]interface{}{"block": []interface{}{"string-not-map"}} + _, _, err := getNestedFirst(attrs, "block") + if err == nil { + t.Fatal("expected error for non-map first element") + } + if !strings.Contains(err.Error(), "want object") { + t.Errorf("error should say 'want object': %v", err) + } + }) +} + +func TestErrorHelpers(t *testing.T) { + if got := errEmptyAttrs("aws_instance").Error(); got != `aws_instance: empty attributes` { + t.Errorf("errEmptyAttrs format unexpected: %q", got) + } + if got := errMissingRequired("aws_db_instance", "engine").Error(); got != `aws_db_instance: missing required attribute "engine"` { + t.Errorf("errMissingRequired format unexpected: %q", got) + } +} diff --git a/internal/iac/aws/rds.go b/internal/iac/aws/rds.go new file mode 100644 index 0000000..fefbecb --- /dev/null +++ b/internal/iac/aws/rds.go @@ -0,0 +1,111 @@ +package aws + +// RDSAttributes captures the cost-impacting fields of an aws_db_instance +// resource. Note: aws_rds_cluster (Aurora cluster-level) is a separate +// resource type and is out of scope for this milestone. +type RDSAttributes struct { + // Engine is the DB engine, e.g. "postgres", "mysql", "mariadb", + // "aurora-postgresql", "aurora-mysql". The aurora-* variants are valid + // because aws_db_instance is also used to declare Aurora *read replicas* + // (the cluster itself uses aws_rds_cluster, but instances attached to + // it are still aws_db_instance). Required. + Engine string + + // EngineVersion pins the engine release, e.g. "15.4". Optional — when + // absent, AWS picks the default version. + EngineVersion string + + // InstanceClass is the DB instance size, e.g. "db.t3.medium". Required. + InstanceClass string + + // AllocatedStorage is the size in GB of the underlying volume. Required. + AllocatedStorage int + + // StorageType selects the EBS-backed storage class (gp2 / gp3 / io1 / + // standard). Defaults to "gp2", matching the AWS provider default. + StorageType string + + // Iops is the provisioned IOPS for io1/gp3 storage. Zero means + // "use the storage class default". Cross-attribute consistency + // (io1 must have iops, gp3 may have iops) is *not* validated here — + // that's a pricing-engine concern. + Iops int + + // MultiAZ enables a synchronous standby replica in another AZ. + // Significant pricing impact (roughly doubles the per-hour cost). + // Defaults to false. + MultiAZ bool +} + +// ExtractRDS reads cost-impacting attributes from an aws_db_instance +// attribute map. +// +// Required: engine, instance_class, allocated_storage. Defaults: +// storage_type="gp2", iops=0, multi_az=false. +// +// The engine field accepts Aurora variants ("aurora-postgresql", +// "aurora-mysql") because aws_db_instance is the resource type for Aurora +// read replicas; the cluster head uses aws_rds_cluster (not handled here). +func ExtractRDS(attrs map[string]interface{}) (*RDSAttributes, error) { + const typ = "aws_db_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + + engine, present, err := getString(attrs, "engine") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "engine") + } + + instanceClass, present, err := getString(attrs, "instance_class") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "instance_class") + } + + allocStorage, present, err := getInt(attrs, "allocated_storage") + if err != nil { + return nil, wrapAttr(typ, err) + } + if !present { + return nil, errMissingRequired(typ, "allocated_storage") + } + + engineVersion, _, err := getString(attrs, "engine_version") + if err != nil { + return nil, wrapAttr(typ, err) + } + + storageType, _, err := getString(attrs, "storage_type") + if err != nil { + return nil, wrapAttr(typ, err) + } + if storageType == "" { + storageType = "gp2" + } + + iops, _, err := getInt(attrs, "iops") + if err != nil { + return nil, wrapAttr(typ, err) + } + + multiAZ, _, err := getBool(attrs, "multi_az") + if err != nil { + return nil, wrapAttr(typ, err) + } + + return &RDSAttributes{ + Engine: engine, + EngineVersion: engineVersion, + InstanceClass: instanceClass, + AllocatedStorage: allocStorage, + StorageType: storageType, + Iops: iops, + MultiAZ: multiAZ, + }, nil +} diff --git a/internal/iac/aws/rds_test.go b/internal/iac/aws/rds_test.go new file mode 100644 index 0000000..5430215 --- /dev/null +++ b/internal/iac/aws/rds_test.go @@ -0,0 +1,181 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractRDS_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "engine": "postgres", + "engine_version": "15.4", + "instance_class": "db.r5.large", + "allocated_storage": float64(500), + "storage_type": "io1", + "iops": float64(10000), + "multi_az": true, + // Unknown future field — must be ignored. + "new_attr": "ignored", + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS: %v", err) + } + want := RDSAttributes{ + Engine: "postgres", + EngineVersion: "15.4", + InstanceClass: "db.r5.large", + AllocatedStorage: 500, + StorageType: "io1", + Iops: 10000, + MultiAZ: true, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractRDS_OnlyRequiredFields(t *testing.T) { + attrs := map[string]interface{}{ + "engine": "mysql", + "instance_class": "db.t3.medium", + "allocated_storage": float64(20), + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS: %v", err) + } + if got.StorageType != "gp2" { + t.Errorf("StorageType = %q, want %q (default)", got.StorageType, "gp2") + } + if got.MultiAZ { + t.Error("MultiAZ = true, want false (default)") + } + if got.Iops != 0 { + t.Errorf("Iops = %d, want 0", got.Iops) + } +} + +// TestExtractRDS_AuroraEngines verifies that aurora-postgresql / aurora-mysql +// are accepted as engine values. aws_db_instance covers Aurora *read replicas* +// (the cluster head is a separate resource type), so these must work. +func TestExtractRDS_AuroraEngines(t *testing.T) { + for _, engine := range []string{"aurora-postgresql", "aurora-mysql"} { + t.Run(engine, func(t *testing.T) { + attrs := map[string]interface{}{ + "engine": engine, + "instance_class": "db.r6g.large", + "allocated_storage": float64(0), + } + got, err := ExtractRDS(attrs) + if err != nil { + t.Fatalf("ExtractRDS(%s): %v", engine, err) + } + if got.Engine != engine { + t.Errorf("Engine = %q, want %q", got.Engine, engine) + } + }) + } +} + +func TestExtractRDS_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no engine", + map[string]interface{}{"instance_class": "db.t3.micro", "allocated_storage": float64(20)}, + `aws_db_instance: missing required attribute "engine"`, + }, + { + "no instance_class", + map[string]interface{}{"engine": "postgres", "allocated_storage": float64(20)}, + `aws_db_instance: missing required attribute "instance_class"`, + }, + { + "no allocated_storage", + map[string]interface{}{"engine": "postgres", "instance_class": "db.t3.micro"}, + `aws_db_instance: missing required attribute "allocated_storage"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractRDS(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractRDS_WrongTypes(t *testing.T) { + base := map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.micro", + "allocated_storage": float64(20), + } + cases := []struct { + name string + key string + val interface{} + }{ + {"engine as int", "engine", 42}, + {"instance_class as bool", "instance_class", true}, + {"engine_version as int", "engine_version", 15}, + {"storage_type as int", "storage_type", 7}, + {"allocated_storage as string", "allocated_storage", "20"}, + {"multi_az as string", "multi_az", "true"}, + {"iops fractional", "iops", 3.14}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := make(map[string]interface{}, len(base)+1) + for k, v := range base { + attrs[k] = v + } + attrs[c.key] = c.val + + _, err := ExtractRDS(attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.key) { + t.Errorf("error should mention %q: %v", c.key, err) + } + }) + } +} + +func TestExtractRDS_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractRDS(attrs) + if err == nil { + t.Fatal("expected error for empty/nil attrs") + } + if err.Error() != "aws_db_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractRDS() { + attrs := map[string]interface{}{ + "engine": "postgres", + "instance_class": "db.t3.medium", + "allocated_storage": float64(100), + "multi_az": true, + } + out, err := ExtractRDS(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s %s, %dGB, multi_az=%v\n", + out.Engine, out.InstanceClass, out.AllocatedStorage, out.MultiAZ) + // Output: postgres db.t3.medium, 100GB, multi_az=true +} From 61a63ccc979d7e059069d5adeb5a8f0ede9c3b03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Thu, 7 May 2026 18:36:26 -0400 Subject: [PATCH 03/21] feat: add support for AWS Lambda, NAT Gateway, and RDS Cluster Instance resource attributes extraction --- internal/iac/aws/aws.go | 38 +++- internal/iac/aws/aws_test.go | 78 +++++++- internal/iac/aws/helpers.go | 33 ++++ internal/iac/aws/helpers_test.go | 66 +++++++ internal/iac/aws/lambda.go | 115 ++++++++++++ internal/iac/aws/lambda_test.go | 173 ++++++++++++++++++ internal/iac/aws/nat.go | 56 ++++++ internal/iac/aws/nat_test.go | 103 +++++++++++ internal/iac/aws/rds_cluster_instance.go | 78 ++++++++ internal/iac/aws/rds_cluster_instance_test.go | 162 ++++++++++++++++ 10 files changed, 896 insertions(+), 6 deletions(-) create mode 100644 internal/iac/aws/lambda.go create mode 100644 internal/iac/aws/lambda_test.go create mode 100644 internal/iac/aws/nat.go create mode 100644 internal/iac/aws/nat_test.go create mode 100644 internal/iac/aws/rds_cluster_instance.go create mode 100644 internal/iac/aws/rds_cluster_instance_test.go diff --git a/internal/iac/aws/aws.go b/internal/iac/aws/aws.go index 4b30d08..816d012 100644 --- a/internal/iac/aws/aws.go +++ b/internal/iac/aws/aws.go @@ -24,10 +24,13 @@ package aws // consumer to add a case (or explicitly default), instead of silently // dispatching a runtime type assertion that no-ops on the new type. type ResourceAttributes struct { - Type string - EC2 *EC2Attributes - RDS *RDSAttributes - EBS *EBSAttributes + Type string + EC2 *EC2Attributes + RDS *RDSAttributes + EBS *EBSAttributes + Lambda *LambdaAttributes + NATGateway *NATGatewayAttributes + RDSClusterInstance *RDSClusterInstanceAttributes } // Extract dispatches to the type-specific extractor for resourceType. @@ -60,6 +63,24 @@ func Extract(resourceType string, attrs map[string]interface{}) (*ResourceAttrib return nil, err } return &ResourceAttributes{Type: resourceType, EBS: ebs}, nil + case "aws_lambda_function": + fn, err := ExtractLambda(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, Lambda: fn}, nil + case "aws_nat_gateway": + nat, err := ExtractNATGateway(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, NATGateway: nat}, nil + case "aws_rds_cluster_instance": + rci, err := ExtractRDSClusterInstance(attrs) + if err != nil { + return nil, err + } + return &ResourceAttributes{Type: resourceType, RDSClusterInstance: rci}, nil } return nil, nil } @@ -69,5 +90,12 @@ func Extract(resourceType string, attrs map[string]interface{}) (*ResourceAttrib // callers can mutate it without affecting future returns. Order is stable // across calls so it can drive menus or docs without sorting. func SupportedTypes() []string { - return []string{"aws_instance", "aws_db_instance", "aws_ebs_volume"} + return []string{ + "aws_instance", + "aws_db_instance", + "aws_ebs_volume", + "aws_lambda_function", + "aws_nat_gateway", + "aws_rds_cluster_instance", + } } diff --git a/internal/iac/aws/aws_test.go b/internal/iac/aws/aws_test.go index 2939d80..10df522 100644 --- a/internal/iac/aws/aws_test.go +++ b/internal/iac/aws/aws_test.go @@ -61,6 +61,59 @@ func TestExtract_DispatchesEBS(t *testing.T) { } } +func TestExtract_DispatchesLambda(t *testing.T) { + r, err := Extract("aws_lambda_function", map[string]interface{}{ + "function_name": "checkout", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_lambda_function" { + t.Errorf("Type = %q", r.Type) + } + if r.Lambda == nil || r.Lambda.FunctionName != "checkout" { + t.Errorf("Lambda not populated: %+v", r) + } + if r.EC2 != nil || r.RDS != nil || r.EBS != nil || + r.NATGateway != nil || r.RDSClusterInstance != nil { + t.Error("non-Lambda fields should be nil") + } +} + +func TestExtract_DispatchesNATGateway(t *testing.T) { + r, err := Extract("aws_nat_gateway", map[string]interface{}{ + "subnet_id": "subnet-123", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_nat_gateway" { + t.Errorf("Type = %q", r.Type) + } + if r.NATGateway == nil || r.NATGateway.SubnetID != "subnet-123" { + t.Errorf("NATGateway not populated: %+v", r) + } +} + +func TestExtract_DispatchesRDSClusterInstance(t *testing.T) { + r, err := Extract("aws_rds_cluster_instance", map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-postgresql", + }) + if err != nil { + t.Fatalf("Extract: %v", err) + } + if r.Type != "aws_rds_cluster_instance" { + t.Errorf("Type = %q", r.Type) + } + if r.RDSClusterInstance == nil || + r.RDSClusterInstance.ClusterIdentifier != "c1" || + r.RDSClusterInstance.Engine != "aurora-postgresql" { + t.Errorf("RDSClusterInstance not populated: %+v", r.RDSClusterInstance) + } +} + // TestExtract_UnsupportedType verifies the contract documented in the // dispatcher comment: unknown types are NOT errors, they are silently // reported as "no data" so the caller can skip them. @@ -102,6 +155,22 @@ func TestExtract_PropagatesUnderlyingErrors(t *testing.T) { "aws_ebs_volume", map[string]interface{}{}, "aws_ebs_volume: empty attributes", }, + { + "Lambda missing required", + "aws_lambda_function", map[string]interface{}{"runtime": "python3.12"}, + `aws_lambda_function: missing required attribute "function_name"`, + }, + { + "NAT empty attrs", + "aws_nat_gateway", nil, + "aws_nat_gateway: empty attributes", + }, + { + "RDS cluster instance missing required", + "aws_rds_cluster_instance", + map[string]interface{}{"cluster_identifier": "c1", "instance_class": "db.t3.medium"}, + `aws_rds_cluster_instance: missing required attribute "engine"`, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -122,7 +191,14 @@ func TestSupportedTypes(t *testing.T) { // but the test asserts membership, not a particular order. sortedGot := append([]string(nil), got...) sort.Strings(sortedGot) - want := []string{"aws_db_instance", "aws_ebs_volume", "aws_instance"} + want := []string{ + "aws_db_instance", + "aws_ebs_volume", + "aws_instance", + "aws_lambda_function", + "aws_nat_gateway", + "aws_rds_cluster_instance", + } if !reflect.DeepEqual(sortedGot, want) { t.Errorf("got %v, want %v", sortedGot, want) } diff --git a/internal/iac/aws/helpers.go b/internal/iac/aws/helpers.go index cb23dc9..13dbf61 100644 --- a/internal/iac/aws/helpers.go +++ b/internal/iac/aws/helpers.go @@ -106,6 +106,39 @@ func getNestedFirst(attrs map[string]interface{}, key string) (map[string]interf return first, true, nil } +// getStringList returns the value at key as a []string slice. +// +// JSON encodes string arrays as []interface{} of string entries, so each +// element gets a type assertion. A non-string element is an error rather +// than a silent skip — partial slices would lose data the caller likely +// needs (Lambda's `architectures`, security_group lists, etc.). +// +// Empty lists are reported as (nil, false, nil) — callers that distinguish +// "explicitly empty" from "absent" don't currently exist; aligning with +// the missing/null behavior keeps the helper predictable. +func getStringList(attrs map[string]interface{}, key string) ([]string, bool, error) { + raw, ok := attrs[key] + if !ok || raw == nil { + return nil, false, nil + } + list, ok := raw.([]interface{}) + if !ok { + return nil, false, fmt.Errorf("attribute %q: want list, got %T", key, raw) + } + if len(list) == 0 { + return nil, false, nil + } + out := make([]string, len(list)) + for i, v := range list { + s, ok := v.(string) + if !ok { + return nil, false, fmt.Errorf("attribute %q[%d]: want string, got %T", key, i, v) + } + out[i] = s + } + return out, true, nil +} + // errEmptyAttrs is the canonical error every extractor returns when its // input map is nil or empty. Centralized so the message stays uniform. func errEmptyAttrs(typ string) error { diff --git a/internal/iac/aws/helpers_test.go b/internal/iac/aws/helpers_test.go index dcacb5e..1d391e7 100644 --- a/internal/iac/aws/helpers_test.go +++ b/internal/iac/aws/helpers_test.go @@ -1,6 +1,7 @@ package aws import ( + "reflect" "strings" "testing" ) @@ -168,6 +169,71 @@ func TestGetNestedFirst(t *testing.T) { }) } +func TestGetStringList(t *testing.T) { + t.Run("multi element", func(t *testing.T) { + attrs := map[string]interface{}{ + "k": []interface{}{"a", "b", "c"}, + } + got, pres, err := getStringList(attrs, "k") + if err != nil || !pres { + t.Fatalf("err=%v pres=%v", err, pres) + } + if !reflect.DeepEqual(got, []string{"a", "b", "c"}) { + t.Errorf("got %v", got) + } + }) + + t.Run("single element", func(t *testing.T) { + attrs := map[string]interface{}{"k": []interface{}{"only"}} + got, _, err := getStringList(attrs, "k") + if err != nil || len(got) != 1 || got[0] != "only" { + t.Errorf("got=%v err=%v", got, err) + } + }) + + t.Run("missing", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v", err, pres) + } + }) + + t.Run("null value", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{"k": nil}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v", err, pres) + } + }) + + t.Run("empty list reported as not present", func(t *testing.T) { + _, pres, err := getStringList(map[string]interface{}{"k": []interface{}{}}, "k") + if err != nil || pres { + t.Errorf("err=%v pres=%v, want nil/false for empty list", err, pres) + } + }) + + t.Run("not a list", func(t *testing.T) { + _, _, err := getStringList(map[string]interface{}{"k": "string-value"}, "k") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "want list") { + t.Errorf("error should say 'want list': %v", err) + } + }) + + t.Run("non-string element", func(t *testing.T) { + attrs := map[string]interface{}{"k": []interface{}{"ok", 42}} + _, _, err := getStringList(attrs, "k") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), `"k"[1]`) { + t.Errorf("error should reference index 1: %v", err) + } + }) +} + func TestErrorHelpers(t *testing.T) { if got := errEmptyAttrs("aws_instance").Error(); got != `aws_instance: empty attributes` { t.Errorf("errEmptyAttrs format unexpected: %q", got) diff --git a/internal/iac/aws/lambda.go b/internal/iac/aws/lambda.go new file mode 100644 index 0000000..6f3f110 --- /dev/null +++ b/internal/iac/aws/lambda.go @@ -0,0 +1,115 @@ +package aws + +// LambdaAttributes captures the cost-impacting fields of an +// aws_lambda_function resource. +// +// Note: Lambda's main cost is invocation-based — per-request charges and +// per-GB-second compute time. The plan only declares the *standing* shape +// (memory, runtime, provisioned concurrency); we don't try to predict +// invocation volume here. The pricing engine in a later milestone will +// combine these attributes with usage assumptions or historical data. +type LambdaAttributes struct { + // FunctionName is the function's logical name. Required. + FunctionName string + + // Runtime is e.g. "python3.12" or "nodejs20.x". Optional and not used + // for pricing — we record it as context for diff messages and logs. + Runtime string + + // MemorySize is the configured memory in MB. AWS scales CPU + // proportionally, so this is the dominant per-invocation cost lever. + // Defaults to 128 MB (Lambda's API default) when absent. + MemorySize int + + // Timeout is the maximum execution time in seconds. Doesn't affect + // per-second pricing, but it does cap the per-invocation cost ceiling. + // Defaults to 3 seconds (Lambda's API default). + Timeout int + + // Architecture is "x86_64" or "arm64". arm64 is ~20% cheaper. Defaults + // to "x86_64" when absent. Validation of the value is intentionally + // deferred to the pricing engine. + Architecture string + + // ProvisionedConcurrency is the count of always-warm executions. Each + // one carries a flat per-hour fee on top of any invocation charges, + // so a non-zero value is what makes Lambda cost predictable from the + // plan alone. Zero means on-demand only. + ProvisionedConcurrency int +} + +// ExtractLambda reads cost-impacting attributes from an aws_lambda_function +// attribute map. +// +// Required: function_name. Defaults: memory_size=128, timeout=3, +// architecture="x86_64", provisioned_concurrency=0. +func ExtractLambda(attrs map[string]interface{}) (*LambdaAttributes, error) { + const typ = "aws_lambda_function" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + functionName, present, err := getString(attrs, "function_name") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "function_name") + } + + runtime, _, err := getString(attrs, "runtime") + if err != nil { + return nil, wrap(err) + } + + memSize, present, err := getInt(attrs, "memory_size") + if err != nil { + return nil, wrap(err) + } + if !present { + memSize = 128 + } + + timeout, present, err := getInt(attrs, "timeout") + if err != nil { + return nil, wrap(err) + } + if !present { + timeout = 3 + } + + // `architectures` is a single-element list in the plan JSON even though + // the API only accepts one architecture per function. The extractor + // reports the first element. Empty/missing lists fall back to x86_64, + // which matches Lambda's default. Value validation (must be x86_64 + // or arm64) is a pricing concern, not a parse concern. + archList, _, err := getStringList(attrs, "architectures") + if err != nil { + return nil, wrap(err) + } + architecture := "x86_64" + if len(archList) > 0 { + architecture = archList[0] + } + + // In newer aws provider versions, provisioned concurrency moved into + // a sub-block (provisioned_concurrency_config { … }). This extractor + // reads only the top-level provisioned_concurrent_executions field; + // extending to the sub-block path is intentionally a future task — + // it would couple this milestone to provider-version detection logic + // we don't need yet. + provisioned, _, err := getInt(attrs, "provisioned_concurrent_executions") + if err != nil { + return nil, wrap(err) + } + + return &LambdaAttributes{ + FunctionName: functionName, + Runtime: runtime, + MemorySize: memSize, + Timeout: timeout, + Architecture: architecture, + ProvisionedConcurrency: provisioned, + }, nil +} diff --git a/internal/iac/aws/lambda_test.go b/internal/iac/aws/lambda_test.go new file mode 100644 index 0000000..cba5fc9 --- /dev/null +++ b/internal/iac/aws/lambda_test.go @@ -0,0 +1,173 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractLambda_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "function_name": "billing-processor", + "runtime": "python3.12", + "memory_size": float64(2048), + "timeout": float64(900), + "architectures": []interface{}{"arm64"}, + "provisioned_concurrent_executions": float64(5), + // Unknown field — must be ignored. + "some_future_attr": "ignored", + } + got, err := ExtractLambda(attrs) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + want := LambdaAttributes{ + FunctionName: "billing-processor", + Runtime: "python3.12", + MemorySize: 2048, + Timeout: 900, + Architecture: "arm64", + ProvisionedConcurrency: 5, + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractLambda_OnlyRequiredFields(t *testing.T) { + got, err := ExtractLambda(map[string]interface{}{ + "function_name": "minimal", + }) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + if got.MemorySize != 128 { + t.Errorf("MemorySize = %d, want 128 (Lambda default)", got.MemorySize) + } + if got.Timeout != 3 { + t.Errorf("Timeout = %d, want 3 (Lambda default)", got.Timeout) + } + if got.Architecture != "x86_64" { + t.Errorf("Architecture = %q, want x86_64 (default)", got.Architecture) + } + if got.ProvisionedConcurrency != 0 { + t.Errorf("ProvisionedConcurrency = %d, want 0", got.ProvisionedConcurrency) + } +} + +// TestExtractLambda_ArchitecturesVariants covers the three shapes the +// architectures field can arrive in: a single-element list (the canonical +// case), an empty list, and an absent field. The latter two must both +// produce the x86_64 default. +func TestExtractLambda_ArchitecturesVariants(t *testing.T) { + cases := []struct { + name string + val interface{} + want string + setIt bool + }{ + {"single element x86_64", []interface{}{"x86_64"}, "x86_64", true}, + {"single element arm64", []interface{}{"arm64"}, "arm64", true}, + {"empty list defaults", []interface{}{}, "x86_64", true}, + {"absent defaults", nil, "x86_64", false}, + // Unknown architecture value passes through — validation belongs + // to the pricing engine, not the extractor. + {"unknown value passes through", []interface{}{"riscv"}, "riscv", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := map[string]interface{}{"function_name": "fn"} + if c.setIt { + attrs["architectures"] = c.val + } + got, err := ExtractLambda(attrs) + if err != nil { + t.Fatalf("ExtractLambda: %v", err) + } + if got.Architecture != c.want { + t.Errorf("Architecture = %q, want %q", got.Architecture, c.want) + } + }) + } +} + +func TestExtractLambda_MissingFunctionName(t *testing.T) { + _, err := ExtractLambda(map[string]interface{}{ + "runtime": "python3.12", + }) + if err == nil { + t.Fatal("expected error for missing function_name") + } + want := `aws_lambda_function: missing required attribute "function_name"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractLambda_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"function_name as int", map[string]interface{}{"function_name": 42}, "function_name"}, + {"runtime as bool", map[string]interface{}{ + "function_name": "fn", "runtime": true, + }, "runtime"}, + {"memory_size as string", map[string]interface{}{ + "function_name": "fn", "memory_size": "1024", + }, "memory_size"}, + {"timeout fractional", map[string]interface{}{ + "function_name": "fn", "timeout": 5.5, + }, "timeout"}, + {"architectures not a list", map[string]interface{}{ + "function_name": "fn", "architectures": "arm64", + }, "architectures"}, + {"architectures contains non-string", map[string]interface{}{ + "function_name": "fn", "architectures": []interface{}{42}, + }, "architectures"}, + {"provisioned_concurrent_executions as string", map[string]interface{}{ + "function_name": "fn", "provisioned_concurrent_executions": "5", + }, "provisioned_concurrent_executions"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractLambda(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_lambda_function") { + t.Errorf("error should start with aws_lambda_function: %v", err) + } + }) + } +} + +func TestExtractLambda_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractLambda(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_lambda_function: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractLambda() { + attrs := map[string]interface{}{ + "function_name": "image-resize", + "memory_size": float64(1024), + "architectures": []interface{}{"arm64"}, + } + out, err := ExtractLambda(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s on %s, %dMB\n", out.FunctionName, out.Architecture, out.MemorySize) + // Output: image-resize on arm64, 1024MB +} diff --git a/internal/iac/aws/nat.go b/internal/iac/aws/nat.go new file mode 100644 index 0000000..5fd5aad --- /dev/null +++ b/internal/iac/aws/nat.go @@ -0,0 +1,56 @@ +package aws + +// NATGatewayAttributes captures the cost-impacting fields of an +// aws_nat_gateway resource. +// +// NAT Gateways are notorious in cost-review meetings because they have +// two charges that surprise people: a flat per-hour fee (~$32/month each, +// 24x7) plus per-GB data processing on every byte that flows through. +// A PR that adds even one of these to a private subnet should produce a +// loud signal in the diff comment. +type NATGatewayAttributes struct { + // SubnetID is the subnet the gateway lives in. Required because + // downstream pricing infers the AZ (and hence the region) from the + // subnet — NAT pricing is regional. We don't fetch the subnet's AZ + // here; that's the diff/pricing engine's job. + SubnetID string + + // ConnectivityType is "public" or "private". Public is the default + // and is what most teams use; "private" is for cross-VPC private + // access without internet egress and has a different price structure. + // Defaults to "public" when absent — matching the AWS provider default. + ConnectivityType string +} + +// ExtractNATGateway reads cost-impacting attributes from an aws_nat_gateway +// attribute map. +// +// Required: subnet_id. Defaults: connectivity_type="public". +func ExtractNATGateway(attrs map[string]interface{}) (*NATGatewayAttributes, error) { + const typ = "aws_nat_gateway" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + subnetID, present, err := getString(attrs, "subnet_id") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "subnet_id") + } + + connType, _, err := getString(attrs, "connectivity_type") + if err != nil { + return nil, wrap(err) + } + if connType == "" { + connType = "public" + } + + return &NATGatewayAttributes{ + SubnetID: subnetID, + ConnectivityType: connType, + }, nil +} diff --git a/internal/iac/aws/nat_test.go b/internal/iac/aws/nat_test.go new file mode 100644 index 0000000..7aebef1 --- /dev/null +++ b/internal/iac/aws/nat_test.go @@ -0,0 +1,103 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractNATGateway_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "subnet_id": "subnet-0a1b2c3d4e5f", + "connectivity_type": "private", + "some_extra": "ignored", + } + got, err := ExtractNATGateway(attrs) + if err != nil { + t.Fatalf("ExtractNATGateway: %v", err) + } + want := NATGatewayAttributes{ + SubnetID: "subnet-0a1b2c3d4e5f", + ConnectivityType: "private", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractNATGateway_OnlyRequiredFields(t *testing.T) { + got, err := ExtractNATGateway(map[string]interface{}{ + "subnet_id": "subnet-deadbeef", + }) + if err != nil { + t.Fatalf("ExtractNATGateway: %v", err) + } + if got.ConnectivityType != "public" { + t.Errorf("ConnectivityType = %q, want public (default)", got.ConnectivityType) + } +} + +func TestExtractNATGateway_MissingSubnetID(t *testing.T) { + _, err := ExtractNATGateway(map[string]interface{}{ + "connectivity_type": "public", + }) + if err == nil { + t.Fatal("expected error for missing subnet_id") + } + want := `aws_nat_gateway: missing required attribute "subnet_id"` + if err.Error() != want { + t.Errorf("error = %q\nwant %q", err.Error(), want) + } +} + +func TestExtractNATGateway_WrongTypes(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + {"subnet_id as int", map[string]interface{}{"subnet_id": 42}, "subnet_id"}, + {"connectivity_type as bool", map[string]interface{}{ + "subnet_id": "subnet-x", + "connectivity_type": true, + }, "connectivity_type"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractNATGateway(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.want) { + t.Errorf("error should mention %q: %v", c.want, err) + } + if !strings.HasPrefix(err.Error(), "aws_nat_gateway") { + t.Errorf("error should start with aws_nat_gateway: %v", err) + } + }) + } +} + +func TestExtractNATGateway_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractNATGateway(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_nat_gateway: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractNATGateway() { + attrs := map[string]interface{}{ + "subnet_id": "subnet-1a2b3c4d", + } + out, err := ExtractNATGateway(attrs) + if err != nil { + panic(err) + } + fmt.Printf("nat in %s (type=%s)\n", out.SubnetID, out.ConnectivityType) + // Output: nat in subnet-1a2b3c4d (type=public) +} diff --git a/internal/iac/aws/rds_cluster_instance.go b/internal/iac/aws/rds_cluster_instance.go new file mode 100644 index 0000000..d459966 --- /dev/null +++ b/internal/iac/aws/rds_cluster_instance.go @@ -0,0 +1,78 @@ +package aws + +// RDSClusterInstanceAttributes captures the cost-impacting fields of an +// aws_rds_cluster_instance resource — i.e. a single Aurora compute node +// attached to a cluster. The cluster header itself (aws_rds_cluster) is +// out of scope for this milestone because its cost is mostly storage, +// while compute-per-instance is what shows up in PRs. +type RDSClusterInstanceAttributes struct { + // ClusterIdentifier links this instance to its parent cluster. + // Required — without it, downstream cost analysis can't associate the + // instance with cluster-level attributes (engine version, storage). + ClusterIdentifier string + + // InstanceClass is the Aurora compute class, e.g. "db.r5.large". Required. + // Aurora pricing uses the same db.* family naming as RDS but a different + // price sheet, so the value passes through untouched. + InstanceClass string + + // Engine is the Aurora engine variant: "aurora-postgresql", + // "aurora-mysql", or the legacy "aurora" (MySQL 5.6). Accepted values + // are intentionally not validated here — the pricing engine in a later + // milestone owns the catalog of recognized engines. + Engine string + + // EngineVersion pins the Aurora release. Optional; AWS picks the + // cluster default when absent. + EngineVersion string +} + +// ExtractRDSClusterInstance reads cost-impacting attributes from an +// aws_rds_cluster_instance attribute map. +// +// Required: cluster_identifier, instance_class, engine. EngineVersion is +// optional. We don't validate engine values against any known list — that +// catalog lives with the pricing engine. +func ExtractRDSClusterInstance(attrs map[string]interface{}) (*RDSClusterInstanceAttributes, error) { + const typ = "aws_rds_cluster_instance" + if len(attrs) == 0 { + return nil, errEmptyAttrs(typ) + } + wrap := func(err error) error { return wrapAttr(typ, err) } + + clusterID, present, err := getString(attrs, "cluster_identifier") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "cluster_identifier") + } + + instanceClass, present, err := getString(attrs, "instance_class") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "instance_class") + } + + engine, present, err := getString(attrs, "engine") + if err != nil { + return nil, wrap(err) + } + if !present { + return nil, errMissingRequired(typ, "engine") + } + + engineVersion, _, err := getString(attrs, "engine_version") + if err != nil { + return nil, wrap(err) + } + + return &RDSClusterInstanceAttributes{ + ClusterIdentifier: clusterID, + InstanceClass: instanceClass, + Engine: engine, + EngineVersion: engineVersion, + }, nil +} diff --git a/internal/iac/aws/rds_cluster_instance_test.go b/internal/iac/aws/rds_cluster_instance_test.go new file mode 100644 index 0000000..ff6dd86 --- /dev/null +++ b/internal/iac/aws/rds_cluster_instance_test.go @@ -0,0 +1,162 @@ +package aws + +import ( + "fmt" + "strings" + "testing" +) + +func TestExtractRDSClusterInstance_HappyPath(t *testing.T) { + attrs := map[string]interface{}{ + "cluster_identifier": "billing-cluster", + "instance_class": "db.r6g.xlarge", + "engine": "aurora-postgresql", + "engine_version": "15.4", + "new_provider_field": "ignored", + } + got, err := ExtractRDSClusterInstance(attrs) + if err != nil { + t.Fatalf("ExtractRDSClusterInstance: %v", err) + } + want := RDSClusterInstanceAttributes{ + ClusterIdentifier: "billing-cluster", + InstanceClass: "db.r6g.xlarge", + Engine: "aurora-postgresql", + EngineVersion: "15.4", + } + if *got != want { + t.Errorf("got %+v\nwant %+v", *got, want) + } +} + +func TestExtractRDSClusterInstance_OnlyRequiredFields(t *testing.T) { + got, err := ExtractRDSClusterInstance(map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-mysql", + }) + if err != nil { + t.Fatalf("ExtractRDSClusterInstance: %v", err) + } + if got.EngineVersion != "" { + t.Errorf("EngineVersion = %q, want empty (optional, absent)", got.EngineVersion) + } +} + +// TestExtractRDSClusterInstance_AcceptsLegacyAuroraEngine confirms that +// "aurora" (the legacy MySQL 5.6 form) is accepted alongside the modern +// aurora-mysql/aurora-postgresql variants. The extractor doesn't validate +// against any catalog — that's the pricing engine's job. +func TestExtractRDSClusterInstance_AcceptsLegacyAuroraEngine(t *testing.T) { + for _, engine := range []string{"aurora", "aurora-mysql", "aurora-postgresql"} { + t.Run(engine, func(t *testing.T) { + got, err := ExtractRDSClusterInstance(map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.r5.large", + "engine": engine, + }) + if err != nil { + t.Fatalf("engine=%s: %v", engine, err) + } + if got.Engine != engine { + t.Errorf("Engine = %q, want %q", got.Engine, engine) + } + }) + } +} + +func TestExtractRDSClusterInstance_MissingRequired(t *testing.T) { + cases := []struct { + name string + attrs map[string]interface{} + want string + }{ + { + "no cluster_identifier", + map[string]interface{}{"instance_class": "db.t3.medium", "engine": "aurora-mysql"}, + `aws_rds_cluster_instance: missing required attribute "cluster_identifier"`, + }, + { + "no instance_class", + map[string]interface{}{"cluster_identifier": "c1", "engine": "aurora-mysql"}, + `aws_rds_cluster_instance: missing required attribute "instance_class"`, + }, + { + "no engine", + map[string]interface{}{"cluster_identifier": "c1", "instance_class": "db.t3.medium"}, + `aws_rds_cluster_instance: missing required attribute "engine"`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := ExtractRDSClusterInstance(c.attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != c.want { + t.Errorf("error = %q\nwant %q", err.Error(), c.want) + } + }) + } +} + +func TestExtractRDSClusterInstance_WrongTypes(t *testing.T) { + base := map[string]interface{}{ + "cluster_identifier": "c1", + "instance_class": "db.t3.medium", + "engine": "aurora-mysql", + } + cases := []struct { + name string + key string + val interface{} + }{ + {"cluster_identifier as int", "cluster_identifier", 42}, + {"instance_class as bool", "instance_class", true}, + {"engine as int", "engine", 7}, + {"engine_version as bool", "engine_version", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + attrs := make(map[string]interface{}, len(base)+1) + for k, v := range base { + attrs[k] = v + } + attrs[c.key] = c.val + + _, err := ExtractRDSClusterInstance(attrs) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), c.key) { + t.Errorf("error should mention %q: %v", c.key, err) + } + }) + } +} + +func TestExtractRDSClusterInstance_NilAndEmpty(t *testing.T) { + for _, attrs := range []map[string]interface{}{nil, {}} { + _, err := ExtractRDSClusterInstance(attrs) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != "aws_rds_cluster_instance: empty attributes" { + t.Errorf("unexpected message: %q", err.Error()) + } + } +} + +func ExampleExtractRDSClusterInstance() { + attrs := map[string]interface{}{ + "cluster_identifier": "billing-cluster", + "instance_class": "db.r6g.large", + "engine": "aurora-postgresql", + } + out, err := ExtractRDSClusterInstance(attrs) + if err != nil { + panic(err) + } + fmt.Printf("%s -> %s (%s)\n", out.ClusterIdentifier, out.InstanceClass, out.Engine) + // Output: billing-cluster -> db.r6g.large (aurora-postgresql) +} From ba1169f8b6494df595a65afbff10ed4b7e77ef66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Thu, 7 May 2026 19:02:41 -0400 Subject: [PATCH 04/21] feat: implement AWS Pricing API client for product lookups with pagination and filtering --- go.mod | 36 ++--- go.sum | 84 ++++++---- internal/pricing/aws.go | 158 +++++++++++++++++++ internal/pricing/aws_test.go | 296 +++++++++++++++++++++++++++++++++++ internal/pricing/doc.go | 28 ++++ 5 files changed, 554 insertions(+), 48 deletions(-) create mode 100644 internal/pricing/aws.go create mode 100644 internal/pricing/aws_test.go create mode 100644 internal/pricing/doc.go diff --git a/go.mod b/go.mod index 69d5d77..dd9b7a9 100644 --- a/go.mod +++ b/go.mod @@ -10,14 +10,18 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v4 v4.1.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/sql/armsql/v2 v2.0.0-beta.7 - github.com/aws/aws-sdk-go-v2/config v1.32.14 + github.com/aws/aws-sdk-go-v2/config v1.32.17 github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1 + github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2 github.com/aws/aws-sdk-go-v2/service/rds v1.118.1 - github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 + github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 github.com/jackc/pgx/v5 v5.9.1 + github.com/testcontainers/testcontainers-go v0.42.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 golang.org/x/sync v0.20.0 google.golang.org/api v0.276.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -33,20 +37,19 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2 v1.41.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect - github.com/aws/smithy-go v1.25.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect + github.com/aws/smithy-go v1.25.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -92,8 +95,6 @@ require ( github.com/shirou/gopsutil/v4 v4.26.3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/testcontainers/testcontainers-go v0.42.0 // indirect - github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect @@ -113,6 +114,5 @@ require ( google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 56053c2..2dba1e7 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ codeberg.org/go-pdf/fpdf v0.11.1 h1:U8+coOTDVLxHIXZgGvkfQEi/q0hYHYvEHFuGNX2GzGs= codeberg.org/go-pdf/fpdf v0.11.1/go.mod h1:Y0DGRAdZ0OmnZPvjbMp/1bYxmIPxm0ws4tfoPOc4LjU= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= @@ -44,44 +46,44 @@ github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgv github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg= -github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ= +github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8= +github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg= -github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= -github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= -github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U= +github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU= +github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU= +github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE= github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0 h1:A+7NViqbMUCoTQFWjbSXdbzE4K5Ziu2zWJtZzAusm+A= github.com/aws/aws-sdk-go-v2/service/ec2 v1.297.0/go.mod h1:R+2BNtUfTfhPY0RH18oL02q116bakeBWjanrbnVBqkM= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA= github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1 h1:JxHLwNK5mIKsh2Q0APTSijdzkk5ccI4gyvYdar1JU/0= github.com/aws/aws-sdk-go-v2/service/lambda v1.89.1/go.mod h1:7qoh/MlWG5QCnZwq9bvdXomEAkmumayXcjEjIemIV7U= +github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2 h1:Ujj2QuBZCrQRek/VmnPwjz6LRGdKoxldpp8fNXwLUUg= +github.com/aws/aws-sdk-go-v2/service/pricing v1.41.2/go.mod h1:zXv2YjVkSugNoBHG8WrHHNqCyTFplsE8B8hCAV9riRA= github.com/aws/aws-sdk-go-v2/service/rds v1.118.1 h1:cywOPYUFOSOAjrovJNxuBXd6SV3osiP3KJ5p412IEJQ= github.com/aws/aws-sdk-go-v2/service/rds v1.118.1/go.mod h1:BaS59j6evm68pt9EaJnb7tnTOaT0MY4rJeESKh8RKKY= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= -github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds= -github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= -github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= -github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk= +github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio= +github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI= +github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -98,6 +100,8 @@ github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpS github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -150,12 +154,20 @@ github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRt github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -186,11 +198,15 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -235,6 +251,8 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -255,6 +273,12 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/pricing/aws.go b/internal/pricing/aws.go new file mode 100644 index 0000000..8054cf1 --- /dev/null +++ b/internal/pricing/aws.go @@ -0,0 +1,158 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + "sort" + + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/pricing" + "github.com/aws/aws-sdk-go-v2/service/pricing/types" +) + +// pricingRegion is the AWS region we pin the Pricing API client to. The +// API is only served from us-east-1 and ap-south-1; us-east-1 is the +// default sane choice. See package doc for the full quirk list. +const pricingRegion = "us-east-1" + +// maxPages caps GetProducts pagination defensively. A real GetProducts +// call rarely returns more than a handful of pages even with broad +// filters; hitting the cap signals a misconfigured filter set (or a +// pathological mock) rather than a genuinely huge result set. +const maxPages = 100 + +// pricingAPI is the subset of *pricing.Client we depend on. Defining it +// as an interface lets tests inject a mock without spinning up an +// httptest server. *pricing.Client satisfies this interface naturally. +type pricingAPI interface { + GetProducts(ctx context.Context, params *pricing.GetProductsInput, opts ...func(*pricing.Options)) (*pricing.GetProductsOutput, error) +} + +// Client wraps the AWS Pricing API for product lookups. Construct it +// with NewClient in production code; tests use newClientWithAPI to +// inject a fake implementation of pricingAPI. +type Client struct { + api pricingAPI +} + +// NewClient creates a production Client using the default AWS credentials +// chain (env vars, shared config, EC2/ECS/EKS metadata, etc.). +// +// Region is forced to us-east-1 — the Pricing API is only available +// there and in ap-south-1, and the endpoint region is independent from +// the region of the priced resource (that's a filter value). +func NewClient(ctx context.Context) (*Client, error) { + cfg, err := awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(pricingRegion)) + if err != nil { + return nil, fmt.Errorf("pricing: loading AWS config: %w", err) + } + return &Client{api: pricing.NewFromConfig(cfg)}, nil +} + +// newClientWithAPI is the test-only constructor. Unexported on purpose — +// production code goes through NewClient so the credentials chain and +// region pinning happen exactly once and the same way everywhere. +func newClientWithAPI(api pricingAPI) *Client { + return &Client{api: api} +} + +// GetProducts queries the Pricing API for products matching the given +// filters and returns the raw PriceList strings. Each string is an +// opaque JSON document; this package does NOT decode them — that's the +// job of per-service mappers in a later milestone. +// +// serviceCode examples: "AmazonEC2", "AmazonRDS", "AmazonEBS". +// +// filters is a map of Pricing API field name → value; every entry is +// translated to a TERM_MATCH filter and the filters are ANDed by the +// service. An empty filters map is valid and returns every product for +// the service. Filters are sorted by field name before being sent so +// that two equivalent calls produce byte-identical request payloads +// (helpful for the upcoming caching layer in milestone 13.2). +// +// Pagination is automatic: if the response carries a NextToken, this +// method follows it until the API stops returning one. As a safety net, +// pagination stops after 100 pages and a slog warning is emitted. +// +// Context cancellation is honored between pages — if ctx is cancelled +// mid-pagination, the partially-collected results are discarded and the +// cancellation error is returned wrapped. +func (c *Client) GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) { + if serviceCode == "" { + return nil, fmt.Errorf("pricing: empty serviceCode") + } + + pricingFilters := buildFilters(filters) + + slog.Info("pricing: GetProducts starting", + "serviceCode", serviceCode, + "filters", len(pricingFilters), + ) + + var ( + all []string + nextToken *string + ) + + for page := 0; page < maxPages; page++ { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("pricing: GetProducts(%s): %w", serviceCode, err) + } + + sc := serviceCode + in := &pricing.GetProductsInput{ + ServiceCode: &sc, + Filters: pricingFilters, + NextToken: nextToken, + } + out, err := c.api.GetProducts(ctx, in) + if err != nil { + return nil, fmt.Errorf("pricing: GetProducts(%s): %w", serviceCode, err) + } + all = append(all, out.PriceList...) + + if out.NextToken == nil || *out.NextToken == "" { + slog.Info("pricing: GetProducts done", + "serviceCode", serviceCode, + "products", len(all), + ) + return all, nil + } + nextToken = out.NextToken + } + + slog.Warn("pricing: GetProducts hit pagination cap", + "serviceCode", serviceCode, + "maxPages", maxPages, + "products", len(all), + ) + return all, nil +} + +// buildFilters converts the caller's map into a sorted []types.Filter +// where every entry has Type=TERM_MATCH. Sorting by field name keeps +// the request payload deterministic across calls with semantically +// identical filter sets — important for the upcoming cache key. +func buildFilters(filters map[string]string) []types.Filter { + if len(filters) == 0 { + return nil + } + fields := make([]string, 0, len(filters)) + for k := range filters { + fields = append(fields, k) + } + sort.Strings(fields) + + out := make([]types.Filter, 0, len(fields)) + for _, f := range fields { + field := f + value := filters[f] + out = append(out, types.Filter{ + Type: types.FilterTypeTermMatch, + Field: &field, + Value: &value, + }) + } + return out +} diff --git a/internal/pricing/aws_test.go b/internal/pricing/aws_test.go new file mode 100644 index 0000000..2e8d336 --- /dev/null +++ b/internal/pricing/aws_test.go @@ -0,0 +1,296 @@ +package pricing + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/pricing" + "github.com/aws/aws-sdk-go-v2/service/pricing/types" +) + +// fakePricing records every GetProducts call and returns the next +// pre-canned response. Designed for table-style tests: pages are +// consumed in order; if the test runs past the end of pages, the fake +// errors loudly so a missing setup is obvious. +type fakePricing struct { + pages []fakePage + calls []pricing.GetProductsInput + + // loop, when set, makes the fake return pages[0] forever instead + // of advancing. Used to exercise the pagination cap. + loop bool + + // onCall is invoked with the (1-indexed) call number before the + // response is returned. Used to inject side effects like cancelling + // the caller's context between pages. + onCall func(callNum int) + + // firstCallErr, when non-nil, is returned on the first call instead + // of pages[0]. Subsequent calls behave as configured. + firstCallErr error +} + +type fakePage struct { + priceList []string + nextToken string // empty means terminal page +} + +func (f *fakePricing) GetProducts(_ context.Context, params *pricing.GetProductsInput, _ ...func(*pricing.Options)) (*pricing.GetProductsOutput, error) { + // Defensive copy: callers may reuse the input struct across calls. + f.calls = append(f.calls, *params) + + if f.onCall != nil { + f.onCall(len(f.calls)) + } + + if len(f.calls) == 1 && f.firstCallErr != nil { + return nil, f.firstCallErr + } + + idx := len(f.calls) - 1 + if f.loop { + idx = 0 + } + if idx >= len(f.pages) { + return nil, fmt.Errorf("fakePricing: unexpected call #%d (only %d pages configured)", len(f.calls), len(f.pages)) + } + + p := f.pages[idx] + out := &pricing.GetProductsOutput{PriceList: p.priceList} + if p.nextToken != "" { + tok := p.nextToken + out.NextToken = &tok + } + return out, nil +} + +func TestGetProducts_Success(t *testing.T) { + want := []string{`{"product":"a"}`, `{"product":"b"}`, `{"product":"c"}`} + fake := &fakePricing{ + pages: []fakePage{{priceList: want}}, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{ + "instanceType": "t3.large", + }) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(got) != len(want) { + t.Fatalf("got %d products, want %d", len(got), len(want)) + } + for i, p := range got { + if p != want[i] { + t.Errorf("product[%d] = %q, want %q", i, p, want[i]) + } + } + if len(fake.calls) != 1 { + t.Errorf("expected exactly 1 API call, got %d", len(fake.calls)) + } + if fake.calls[0].ServiceCode == nil || *fake.calls[0].ServiceCode != "AmazonEC2" { + t.Errorf("ServiceCode not propagated: %+v", fake.calls[0].ServiceCode) + } +} + +func TestGetProducts_Pagination(t *testing.T) { + fake := &fakePricing{ + pages: []fakePage{ + {priceList: []string{"p1", "p2"}, nextToken: "tok-page-2"}, + {priceList: []string{"p3"}}, + }, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + want := []string{"p1", "p2", "p3"} + if len(got) != len(want) { + t.Fatalf("got %d products, want %d (%v)", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("product[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if len(fake.calls) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(fake.calls)) + } + // First call: no NextToken. + if fake.calls[0].NextToken != nil { + t.Errorf("first call NextToken = %q, want nil", *fake.calls[0].NextToken) + } + // Second call: NextToken from the first response. + if fake.calls[1].NextToken == nil || *fake.calls[1].NextToken != "tok-page-2" { + t.Errorf("second call NextToken = %v, want %q", fake.calls[1].NextToken, "tok-page-2") + } +} + +func TestGetProducts_FiltersMappedCorrectly(t *testing.T) { + fake := &fakePricing{pages: []fakePage{{priceList: nil}}} + c := newClientWithAPI(fake) + + in := map[string]string{ + "instanceType": "t3.large", + "regionCode": "us-east-2", + "operatingSystem": "Linux", + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", in); err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(fake.calls) != 1 { + t.Fatalf("expected 1 call, got %d", len(fake.calls)) + } + got := fake.calls[0].Filters + if len(got) != len(in) { + t.Fatalf("got %d filters, want %d", len(got), len(in)) + } + for _, f := range got { + if f.Type != types.FilterTypeTermMatch { + t.Errorf("filter type = %q, want TERM_MATCH", f.Type) + } + if f.Field == nil || f.Value == nil { + t.Fatalf("filter has nil Field/Value: %+v", f) + } + want, ok := in[*f.Field] + if !ok { + t.Errorf("unexpected filter field %q", *f.Field) + continue + } + if *f.Value != want { + t.Errorf("filter %q = %q, want %q", *f.Field, *f.Value, want) + } + } +} + +func TestGetProducts_EmptyServiceCode(t *testing.T) { + fake := &fakePricing{} + c := newClientWithAPI(fake) + + _, err := c.GetProducts(context.Background(), "", nil) + if err == nil { + t.Fatal("expected error for empty serviceCode") + } + if err.Error() != "pricing: empty serviceCode" { + t.Errorf("error = %q, want %q", err.Error(), "pricing: empty serviceCode") + } + if len(fake.calls) != 0 { + t.Errorf("API was called %d times despite empty serviceCode", len(fake.calls)) + } +} + +func TestGetProducts_APIError(t *testing.T) { + apiErr := errors.New("AccessDenied: not authorized") + fake := &fakePricing{firstCallErr: apiErr} + c := newClientWithAPI(fake) + + _, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, apiErr) { + t.Errorf("error does not wrap underlying API error: %v", err) + } + if !strings.Contains(err.Error(), "pricing: GetProducts(AmazonEC2):") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } +} + +func TestGetProducts_PaginationCap(t *testing.T) { + // Fake returns NextToken on every call — without the cap, the + // loop would run forever. With the cap, it should stop after + // maxPages and return what was collected. + fake := &fakePricing{ + pages: []fakePage{{priceList: []string{"p"}, nextToken: "always"}}, + loop: true, + } + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(fake.calls) != maxPages { + t.Errorf("call count = %d, want %d (pagination cap)", len(fake.calls), maxPages) + } + if len(got) != maxPages { + t.Errorf("product count = %d, want %d (one product per page)", len(got), maxPages) + } +} + +func TestGetProducts_ContextCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + fake := &fakePricing{ + pages: []fakePage{ + {priceList: []string{"p1"}, nextToken: "tok-2"}, + {priceList: []string{"p2"}, nextToken: "tok-3"}, + {priceList: []string{"p3"}}, + }, + // Cancel the parent context after the first successful page so + // the next iteration's ctx.Err() check trips. + onCall: func(n int) { + if n == 1 { + cancel() + } + }, + } + c := newClientWithAPI(fake) + + _, err := c.GetProducts(ctx, "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error from cancelled context") + } + if !errors.Is(err, context.Canceled) { + t.Errorf("error does not wrap context.Canceled: %v", err) + } + if !strings.Contains(err.Error(), "pricing: GetProducts(AmazonEC2):") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } + // Page 1 succeeded; the cancellation should have prevented page 2. + if len(fake.calls) != 1 { + t.Errorf("expected exactly 1 call before cancellation, got %d", len(fake.calls)) + } +} + +func TestGetProducts_EmptyFilters(t *testing.T) { + // Empty/nil filters must be allowed (queries every product for the + // service). Verify the call goes through with zero filters. + fake := &fakePricing{pages: []fakePage{{priceList: []string{"x"}}}} + c := newClientWithAPI(fake) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if len(got) != 1 { + t.Errorf("got %d products, want 1", len(got)) + } + if len(fake.calls[0].Filters) != 0 { + t.Errorf("expected 0 filters, got %d", len(fake.calls[0].Filters)) + } +} + +func TestNewClient_RegionForced(t *testing.T) { + // LoadDefaultConfig touches the local AWS config files / env vars. + // On a clean CI box without any AWS env, it still succeeds (config + // loading is independent from credential resolution). If for some + // reason the environment makes it fail, skip rather than fail — + // the property under test is the region, not the credentials chain. + c, err := NewClient(context.Background()) + if err != nil { + t.Skipf("LoadDefaultConfig failed in this environment: %v", err) + } + pc, ok := c.api.(*pricing.Client) + if !ok { + t.Fatalf("api is %T, want *pricing.Client", c.api) + } + if got := pc.Options().Region; got != pricingRegion { + t.Errorf("client Region = %q, want %q", got, pricingRegion) + } +} diff --git a/internal/pricing/doc.go b/internal/pricing/doc.go new file mode 100644 index 0000000..97c655d --- /dev/null +++ b/internal/pricing/doc.go @@ -0,0 +1,28 @@ +// Package pricing wraps the AWS Pricing API for product lookups used by +// downstream cost-impact analysis. +// +// Scope: this package is a thin foundation client. It deliberately does +// NOT cache results, does NOT parse the per-product JSON returned by the +// API, and does NOT translate Terraform attribute structs into Pricing +// API filters. Those responsibilities live in later milestones (caching, +// per-service attribute mappers, monthly cost estimation). +// +// Quirks of the AWS Pricing API that this package handles for callers: +// +// 1. The Pricing API service endpoint is only available in us-east-1 +// and ap-south-1. NewClient hard-codes us-east-1 because it's the +// default sane choice. The endpoint region is unrelated to the region +// of the priced resource — that comes through as a regionCode filter +// value in the GetProducts call. +// +// 2. Products come back as a []string of opaque JSON documents on the +// PriceList field (each ~10–50 KB). We pass them through unparsed; +// per-service mappers in a later milestone are responsible for +// decoding them. Decoding here would couple this foundation to every +// supported resource type. +// +// 3. Filters are list-of-(field, value) with a filter type. We force +// TERM_MATCH for every filter — it's the only one we use today, and +// ANDing exact matches is what every downstream caller wants. +// ANY_OF / NONE_OF / CONTAINS are deliberately not exposed. +package pricing From 0c47503a9fcb6b04b97535fbd5d0e1c84f1c5b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Thu, 7 May 2026 19:18:55 -0400 Subject: [PATCH 05/21] feat: implement disk-based caching for AWS Pricing API product lookups --- internal/pricing/cache.go | 278 +++++++++++++++++++++ internal/pricing/cache_test.go | 431 +++++++++++++++++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 internal/pricing/cache.go create mode 100644 internal/pricing/cache_test.go diff --git a/internal/pricing/cache.go b/internal/pricing/cache.go new file mode 100644 index 0000000..6a47efd --- /dev/null +++ b/internal/pricing/cache.go @@ -0,0 +1,278 @@ +package pricing + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// productGetter is the abstraction Cache wraps. *Client satisfies it +// naturally; tests inject a fake. Defining it here (and not exporting) +// keeps the cache focused on a single shape without leaking an interface +// that callers might mistakenly depend on instead of *Client. +type productGetter interface { + GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) +} + +// Cache wraps a productGetter with disk-based caching of GetProducts +// results. +// +// Entries are stored as JSON files under dir, keyed by sha256 of +// (serviceCode + sorted filters). Entries older than ttl are treated as +// misses and refreshed from the underlying source. The on-disk format is +// described on cacheEntry. +// +// All cache operations are best-effort: if a read fails for any reason +// (missing file, IO error, corrupt JSON) the underlying source is +// consulted and its result returned. If a write fails, the products are +// still returned to the caller. Cache failures emit slog.Warn but never +// propagate to the caller — the cache must not break the happy path. +// +// Concurrent access: there is no explicit locking. Two processes +// hitting the same entry at the same time may both call the underlying +// source and both write the file; the OS gives last-writer-wins, which +// is acceptable for a TTL'd best-effort cache. Writes use a temp-file + +// rename so a reader never observes a half-written file. +type Cache struct { + inner productGetter + dir string + ttl time.Duration +} + +// cacheEntry is the on-disk shape of a single cache record. +// +// service_code and filters are denormalized into the file purely for +// human debugging — the cache integrity is guaranteed by the sha256 +// filename, and the loader does NOT cross-check these fields against +// the request. Treat them as "what was this entry for?" annotations +// rather than authoritative data. +type cacheEntry struct { + ServiceCode string `json:"service_code"` + Filters map[string]string `json:"filters"` + FetchedAt time.Time `json:"fetched_at"` + Products []string `json:"products"` +} + +// NewCache creates a Cache backed by inner, storing entries in dir with +// the given TTL. dir is created (with os.MkdirAll) if it doesn't exist. +// +// Returns an error only if dir cannot be created — that's a +// configuration problem (bad path, permission denied) and surfacing it +// at construction time avoids confusing best-effort failures later. +// +// Recommended ttl is 7 days: AWS public prices change rarely, and +// stale-by-a-week is much cheaper than hammering the API on every PR +// update. Recommended dir is DefaultCacheDir(). +func NewCache(inner productGetter, dir string, ttl time.Duration) (*Cache, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("pricing: creating cache dir %q: %w", dir, err) + } + return &Cache{inner: inner, dir: dir, ttl: ttl}, nil +} + +// DefaultCacheDir returns the recommended on-disk cache location: +// - Windows: %USERPROFILE%\.cloudoracle\pricing-cache +// - Unix: $HOME/.cloudoracle/pricing-cache +// +// Returns an error if the user's home directory cannot be determined +// (rare; happens in stripped-down container environments). +func DefaultCacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("pricing: resolving user home dir: %w", err) + } + return filepath.Join(home, ".cloudoracle", "pricing-cache"), nil +} + +// GetProducts checks the cache before consulting the underlying source. +// +// Hit (file present, valid JSON, within TTL): returns the cached +// products. inner is not called. +// Miss (file absent, IO error, corrupt JSON, or expired): calls +// inner.GetProducts and writes the result to disk. Corrupt and expired +// files are removed opportunistically. Cache write failures log a +// warning but do not affect the returned data. +// +// If the underlying source returns an error, that error is propagated +// verbatim and nothing is written to disk. +func (c *Cache) GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) { + key := cacheKey(serviceCode, filters) + path := filepath.Join(c.dir, key+".json") + + if products, ok := c.readEntry(path); ok { + slog.Debug("pricing: cache hit", + "serviceCode", serviceCode, + "key", key, + ) + return products, nil + } + + slog.Debug("pricing: cache miss", + "serviceCode", serviceCode, + "key", key, + ) + + products, err := c.inner.GetProducts(ctx, serviceCode, filters) + if err != nil { + return nil, err + } + + c.writeEntry(path, cacheEntry{ + ServiceCode: serviceCode, + Filters: filters, + FetchedAt: time.Now().UTC(), + Products: products, + }) + + return products, nil +} + +// readEntry returns (products, true) on a valid, fresh cache hit and +// (nil, false) for any miss reason. Corrupt and expired files are +// deleted before returning so the next miss path doesn't keep tripping +// over the same garbage. +func (c *Cache) readEntry(path string) ([]string, bool) { + raw, err := os.ReadFile(path) + if err != nil { + // A missing file is the common case — don't even debug-log it. + // IO errors on a present file are worth a warn. + if !errors.Is(err, fs.ErrNotExist) { + slog.Warn("pricing: cache read failed", + "path", path, + "error", err, + ) + } + return nil, false + } + + var entry cacheEntry + if err := json.Unmarshal(raw, &entry); err != nil { + slog.Warn("pricing: cache file corrupt, removing", + "path", path, + "error", err, + ) + if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + slog.Warn("pricing: failed to remove corrupt cache file", + "path", path, + "error", rmErr, + ) + } + return nil, false + } + + if time.Since(entry.FetchedAt) > c.ttl { + slog.Debug("pricing: cache entry expired", + "path", path, + "fetched_at", entry.FetchedAt, + ) + if rmErr := os.Remove(path); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + slog.Warn("pricing: failed to remove expired cache file", + "path", path, + "error", rmErr, + ) + } + return nil, false + } + + return entry.Products, true +} + +// writeEntry persists an entry atomically: marshal, write to a temp +// sibling, rename into place. Any failure is logged at warn and +// swallowed — the caller already has the data and shouldn't be +// penalised because we couldn't write to disk. +func (c *Cache) writeEntry(path string, entry cacheEntry) { + body, err := json.Marshal(entry) + if err != nil { + // json.Marshal on a struct of strings/time/[]string shouldn't + // fail in practice, but log if it ever does instead of panicking. + slog.Warn("pricing: cache marshal failed", + "path", path, + "error", err, + ) + return + } + + tmp, err := os.CreateTemp(c.dir, "tmp-*.json") + if err != nil { + slog.Warn("pricing: cache temp file create failed", + "dir", c.dir, + "error", err, + ) + return + } + tmpPath := tmp.Name() + + if _, err := tmp.Write(body); err != nil { + slog.Warn("pricing: cache write failed", + "path", tmpPath, + "error", err, + ) + _ = tmp.Close() + _ = os.Remove(tmpPath) + return + } + if err := tmp.Close(); err != nil { + slog.Warn("pricing: cache temp file close failed", + "path", tmpPath, + "error", err, + ) + _ = os.Remove(tmpPath) + return + } + + if err := os.Rename(tmpPath, path); err != nil { + slog.Warn("pricing: cache rename failed", + "from", tmpPath, + "to", path, + "error", err, + ) + _ = os.Remove(tmpPath) + return + } +} + +// cacheKey computes the deterministic cache key for a lookup. +// +// Layout: sha256( serviceCode + "\n" + sortedFiltersSerialized ). +// sortedFiltersSerialized joins filter entries by alphabetical key as +// "key=value\n" pairs, producing the same string for nil filters and +// empty maps (both serialize to ""). +func cacheKey(serviceCode string, filters map[string]string) string { + var sb strings.Builder + sb.WriteString(serviceCode) + sb.WriteByte('\n') + sb.WriteString(serializeFilters(filters)) + sum := sha256.Sum256([]byte(sb.String())) + return hex.EncodeToString(sum[:]) +} + +func serializeFilters(filters map[string]string) string { + if len(filters) == 0 { + return "" + } + keys := make([]string, 0, len(filters)) + for k := range filters { + keys = append(keys, k) + } + sort.Strings(keys) + + var sb strings.Builder + for _, k := range keys { + sb.WriteString(k) + sb.WriteByte('=') + sb.WriteString(filters[k]) + sb.WriteByte('\n') + } + return sb.String() +} diff --git a/internal/pricing/cache_test.go b/internal/pricing/cache_test.go new file mode 100644 index 0000000..6eab365 --- /dev/null +++ b/internal/pricing/cache_test.go @@ -0,0 +1,431 @@ +package pricing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// fakeProductGetter is a productGetter that records calls and replies +// from a script. Designed for cache tests where we need to assert +// "inner was/was not called" with precision. +type fakeProductGetter struct { + products []string + err error + calls int + // lastService and lastFilters record the most recent input so tests + // can verify forwarding behaviour without dragging in extra fields. + lastService string + lastFilters map[string]string +} + +func (f *fakeProductGetter) GetProducts(_ context.Context, serviceCode string, filters map[string]string) ([]string, error) { + f.calls++ + f.lastService = serviceCode + f.lastFilters = filters + if f.err != nil { + return nil, f.err + } + return f.products, nil +} + +// captureLogs swaps slog.Default for a text handler writing into the +// returned buffer for the duration of the test. Restored via t.Cleanup. +func captureLogs(t *testing.T, level slog.Level) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: level}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// readCachedEntry decodes the cache file at path. Used by tests that +// need to assert what we wrote. +func readCachedEntry(t *testing.T, path string) cacheEntry { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading cache file %q: %v", path, err) + } + var e cacheEntry + if err := json.Unmarshal(raw, &e); err != nil { + t.Fatalf("decoding cache file %q: %v", path, err) + } + return e +} + +// listCacheFiles returns every .json file in dir, ignoring temp files. +func listCacheFiles(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("reading dir %q: %v", dir, err) + } + var out []string + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".json") && !strings.HasPrefix(name, "tmp-") { + out = append(out, filepath.Join(dir, name)) + } + } + return out +} + +func TestCache_MissCallsInnerAndStores(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{`{"a":1}`, `{"b":2}`}} + c, err := NewCache(fake, dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{ + "instanceType": "t3.large", + }) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 2 || got[0] != `{"a":1}` || got[1] != `{"b":2}` { + t.Errorf("returned products = %v, want fake's response", got) + } + + files := listCacheFiles(t, dir) + if len(files) != 1 { + t.Fatalf("got %d cache files, want 1", len(files)) + } + entry := readCachedEntry(t, files[0]) + if entry.ServiceCode != "AmazonEC2" { + t.Errorf("ServiceCode = %q", entry.ServiceCode) + } + if entry.Filters["instanceType"] != "t3.large" { + t.Errorf("Filters not persisted: %+v", entry.Filters) + } + if len(entry.Products) != 2 { + t.Errorf("persisted Products len = %d, want 2", len(entry.Products)) + } + if entry.FetchedAt.IsZero() { + t.Error("FetchedAt is zero") + } +} + +func TestCache_HitDoesNotCallInner(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"should-not-be-returned"}} + c, _ := NewCache(fake, dir, time.Hour) + + // Pre-populate by writing the entry file directly. + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + cached := cacheEntry{ + ServiceCode: "AmazonEC2", + FetchedAt: time.Now().UTC(), + Products: []string{"cached-1", "cached-2"}, + } + body, _ := json.Marshal(cached) + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 0 { + t.Errorf("inner was called %d times on a hit", fake.calls) + } + if len(got) != 2 || got[0] != "cached-1" || got[1] != "cached-2" { + t.Errorf("returned products = %v, want cached entries", got) + } +} + +func TestCache_ExpiredEntryRefreshes(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"fresh-1"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + stale := cacheEntry{ + ServiceCode: "AmazonEC2", + FetchedAt: time.Now().UTC().Add(-2 * time.Hour), + Products: []string{"stale-1"}, + } + body, _ := json.Marshal(stale) + if err := os.WriteFile(path, body, 0o644); err != nil { + t.Fatalf("seed cache: %v", err) + } + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (expired entry should refresh)", fake.calls) + } + if len(got) != 1 || got[0] != "fresh-1" { + t.Errorf("returned products = %v, want fresh response", got) + } + + // File should now be the refreshed entry, not the stale one. + entry := readCachedEntry(t, path) + if len(entry.Products) != 1 || entry.Products[0] != "fresh-1" { + t.Errorf("file not refreshed: %+v", entry) + } + if time.Since(entry.FetchedAt) > time.Minute { + t.Errorf("FetchedAt not updated: %v", entry.FetchedAt) + } +} + +func TestCache_CorruptFileTreatedAsMiss(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"recovered"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + path := filepath.Join(dir, key+".json") + if err := os.WriteFile(path, []byte("not-json{{{"), 0o644); err != nil { + t.Fatalf("seed corrupt file: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (corrupt file should miss)", fake.calls) + } + if len(got) != 1 || got[0] != "recovered" { + t.Errorf("returned products = %v, want fresh response", got) + } + + if !strings.Contains(logs.String(), "cache file corrupt") { + t.Errorf("expected warn log about corrupt file, got: %s", logs.String()) + } + + // File should be replaced with a valid one. + entry := readCachedEntry(t, path) + if len(entry.Products) != 1 || entry.Products[0] != "recovered" { + t.Errorf("file not replaced with valid entry: %+v", entry) + } +} + +func TestCache_NilAndEmptyFiltersSameKey(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + if _, err := c.GetProducts(context.Background(), "AmazonEC2", nil); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", map[string]string{}); err != nil { + t.Fatalf("second call: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (nil and {} must hash equal)", fake.calls) + } +} + +func TestCache_DifferentFiltersOrderSameKey(t *testing.T) { + // Go maps don't actually carry insertion order so the test is really + // asserting "same {key:value} set hashes to the same key regardless + // of how the caller built the map". + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + first := map[string]string{"a": "1", "b": "2"} + second := map[string]string{"b": "2", "a": "1"} + + if _, err := c.GetProducts(context.Background(), "AmazonEC2", first); err != nil { + t.Fatalf("first call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonEC2", second); err != nil { + t.Fatalf("second call: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1 (filter order must not affect key)", fake.calls) + } + if files := listCacheFiles(t, dir); len(files) != 1 { + t.Errorf("got %d cache files, want 1", len(files)) + } +} + +func TestCache_DifferentServicesDifferentKeys(t *testing.T) { + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"x"}} + c, _ := NewCache(fake, dir, time.Hour) + + filters := map[string]string{"region": "us-east-1"} + if _, err := c.GetProducts(context.Background(), "AmazonEC2", filters); err != nil { + t.Fatalf("EC2 call: %v", err) + } + if _, err := c.GetProducts(context.Background(), "AmazonRDS", filters); err != nil { + t.Fatalf("RDS call: %v", err) + } + + if fake.calls != 2 { + t.Errorf("inner calls = %d, want 2 (different services miss separately)", fake.calls) + } + if files := listCacheFiles(t, dir); len(files) != 2 { + t.Errorf("got %d cache files, want 2", len(files)) + } +} + +func TestCache_WriteFailureLogsButReturns(t *testing.T) { + // Cross-platform recipe: build the Cache, then remove the dir + // underneath it. The next write attempt fails at CreateTemp, the + // cache logs a warn, and the data is still returned to the caller. + // This avoids Windows' very different ACL semantics. + parent := t.TempDir() + dir := filepath.Join(parent, "pricing-cache") + fake := &fakeProductGetter{products: []string{"abc"}} + c, err := NewCache(fake, dir, time.Hour) + if err != nil { + t.Fatalf("NewCache: %v", err) + } + + if err := os.RemoveAll(dir); err != nil { + t.Fatalf("removing cache dir: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts returned error on write failure: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 1 || got[0] != "abc" { + t.Errorf("returned products = %v, want fake's response", got) + } + if !strings.Contains(logs.String(), "level=WARN") { + t.Errorf("expected warn log on write failure, got: %s", logs.String()) + } +} + +func TestCache_ReadFailureTreatedAsMiss(t *testing.T) { + // When os.ReadFile on the cache path returns an error other than + // fs.ErrNotExist, we want a warn log and a fall-through to the inner + // source. Simulate that by occupying the cache path with a directory: + // reading a directory errors with EISDIR (Unix) / access-denied + // (Windows), neither of which is fs.ErrNotExist. + // + // This also exercises the writeEntry rename-failure branch, because + // the post-miss write tries to Rename a tmp file over the directory + // we created — Rename fails, the warn is logged, and the products + // still come back to the caller. + dir := t.TempDir() + fake := &fakeProductGetter{products: []string{"recovered"}} + c, _ := NewCache(fake, dir, time.Hour) + + key := cacheKey("AmazonEC2", nil) + blockingPath := filepath.Join(dir, key+".json") + if err := os.Mkdir(blockingPath, 0o755); err != nil { + t.Fatalf("mkdir blocker: %v", err) + } + + logs := captureLogs(t, slog.LevelWarn) + + got, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err != nil { + t.Fatalf("GetProducts returned error on read failure: %v", err) + } + if fake.calls != 1 { + t.Errorf("inner calls = %d, want 1", fake.calls) + } + if len(got) != 1 || got[0] != "recovered" { + t.Errorf("returned products = %v, want fresh response", got) + } + if !strings.Contains(logs.String(), "cache read failed") { + t.Errorf("expected warn log about read failure, got: %s", logs.String()) + } +} + +func TestNewCache_BadDir(t *testing.T) { + // Create a regular file, then try to use a path under it as the + // cache dir. MkdirAll cannot create a directory below a file on any + // supported platform, so this exercises the error branch in NewCache + // without relying on platform-specific permissions. + parent := t.TempDir() + blocker := filepath.Join(parent, "blocker") + if err := os.WriteFile(blocker, nil, 0o644); err != nil { + t.Fatalf("creating blocker file: %v", err) + } + bad := filepath.Join(blocker, "child") + + _, err := NewCache(&fakeProductGetter{}, bad, time.Hour) + if err == nil { + t.Fatal("expected error from NewCache with un-creatable dir") + } + if !strings.Contains(err.Error(), "pricing: creating cache dir") { + t.Errorf("error missing wrap prefix: %q", err.Error()) + } +} + +func TestCache_PropagatesInnerError(t *testing.T) { + dir := t.TempDir() + innerErr := errors.New("AccessDenied: not authorized") + fake := &fakeProductGetter{err: innerErr} + c, _ := NewCache(fake, dir, time.Hour) + + _, err := c.GetProducts(context.Background(), "AmazonEC2", nil) + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner error: %v", err) + } + if files := listCacheFiles(t, dir); len(files) != 0 { + t.Errorf("got %d cache files, want 0 (errors must not be cached)", len(files)) + } +} + +func TestNewCache_CreatesDirIfMissing(t *testing.T) { + parent := t.TempDir() + target := filepath.Join(parent, "nested", "cache") + + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("precondition: target should not exist yet, stat: %v", err) + } + + fake := &fakeProductGetter{} + if _, err := NewCache(fake, target, time.Hour); err != nil { + t.Fatalf("NewCache: %v", err) + } + + info, err := os.Stat(target) + if err != nil { + t.Fatalf("stat after NewCache: %v", err) + } + if !info.IsDir() { + t.Errorf("%q is not a directory", target) + } +} + +func TestDefaultCacheDir(t *testing.T) { + got, err := DefaultCacheDir() + if err != nil { + t.Fatalf("DefaultCacheDir: %v", err) + } + if got == "" { + t.Fatal("DefaultCacheDir returned empty path") + } + if filepath.Base(got) != "pricing-cache" { + t.Errorf("DefaultCacheDir = %q, want a path ending in pricing-cache", got) + } +} From 989db0a542104da3c3a3c3acd8714d9c2489f2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 12:12:34 -0400 Subject: [PATCH 06/21] feat: add EC2 cost estimation logic with support for root EBS volume pricing --- internal/pricing/ec2.go | 183 ++++++++ internal/pricing/ec2_test.go | 418 ++++++++++++++++++ internal/pricing/parse.go | 82 ++++ internal/pricing/parse_test.go | 201 +++++++++ .../pricing/testdata/ec2_gp3_us_east_2.json | 36 ++ .../testdata/ec2_t3_large_us_east_2.json | 40 ++ internal/pricing/types.go | 44 ++ 7 files changed, 1004 insertions(+) create mode 100644 internal/pricing/ec2.go create mode 100644 internal/pricing/ec2_test.go create mode 100644 internal/pricing/parse.go create mode 100644 internal/pricing/parse_test.go create mode 100644 internal/pricing/testdata/ec2_gp3_us_east_2.json create mode 100644 internal/pricing/testdata/ec2_t3_large_us_east_2.json create mode 100644 internal/pricing/types.go diff --git a/internal/pricing/ec2.go b/internal/pricing/ec2.go new file mode 100644 index 0000000..3599b32 --- /dev/null +++ b/internal/pricing/ec2.go @@ -0,0 +1,183 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// HoursPerMonth is the AWS-standard month length used to convert hourly +// On-Demand prices to a monthly figure. AWS uses 730 across every public +// pricing page (see https://aws.amazon.com/ec2/pricing/on-demand/). Cited +// inline because future readers always ask why 730 rather than 720, 744, +// or 8760/12. +const HoursPerMonth = 730 + +// EstimateEC2 calculates the monthly cost of a single EC2 instance using +// the AWS Pricing API. The total includes the compute hours and, when a +// root_block_device is present in the plan, the root EBS volume's +// GB-month rate. +// +// region is the AWS region of the resource (e.g. "us-east-2"). The +// Pricing API endpoint region — pinned to us-east-1 inside Client — is +// independent of this value: the resource's region is supplied as a +// regionCode filter. +// +// src can be a *Client (live calls) or a *Cache (disk-cached); the caller +// chooses. Tests inject a fake productGetter. +// +// The mapper applies four assumptions, each of which contributes to the +// returned Confidence being ConfidenceLow: +// +// 1. operatingSystem = "Linux". Terraform plans don't carry the OS — it +// comes from the AMI, and resolving the AMI requires an extra +// ec2:DescribeImages call (out of scope here). +// 2. preInstalledSw = "NA". No SQL Server, SAP, or other licensed +// software sold by the hour through AWS. +// 3. capacitystatus = "Used". On-Demand only — Reserved Instances, +// Savings Plans, and Capacity Reservations are not modelled. +// 4. tenancy mapping. Terraform's lowercase "default"/"dedicated"/"host" +// maps to the Pricing API's "Shared"/"Dedicated"/"Host" via +// mapTenancy. +// +// Returns an error for nil attrs, empty region, empty InstanceType, API +// failures, products that come back empty, parse failures, or any unit +// mismatch (compute price not in "Hrs", storage price not in "GB-Mo"). +// A unit mismatch usually means the filters were too loose and matched +// the wrong product family — failing fast surfaces it to the developer. +func EstimateEC2(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateEC2: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateEC2: nil attrs") + } + if attrs.InstanceType == "" { + return Estimate{}, fmt.Errorf("EstimateEC2: empty InstanceType") + } + + compute, err := lookupComputePrice(ctx, src, attrs, region) + if err != nil { + return Estimate{}, err + } + + notes := []string{ + "Operating system assumed Linux (plan does not specify)", + "Pricing assumes On-Demand (no Reserved Instances or Savings Plans)", + } + breakdown := []LineItem{{Component: "Compute", MonthlyUSD: compute}} + total := compute + + if attrs.RootBlockSize > 0 { + rootEBS, err := lookupRootEBSPrice(ctx, src, attrs, region) + if err != nil { + return Estimate{}, err + } + breakdown = append(breakdown, LineItem{Component: "RootEBS", MonthlyUSD: rootEBS}) + total += rootEBS + } else { + notes = append(notes, "Root block device unknown, compute-only estimate") + } + + return Estimate{ + MonthlyUSD: total, + Currency: "USD", + Breakdown: breakdown, + Confidence: ConfidenceLow, + Notes: notes, + }, nil +} + +// lookupComputePrice runs the Pricing API query for the EC2 compute +// component and returns the monthly USD cost (hourly price * 730). +func lookupComputePrice(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (float64, error) { + filters := map[string]string{ + "productFamily": "Compute Instance", + "instanceType": attrs.InstanceType, + "regionCode": region, + "tenancy": mapTenancy(attrs.Tenancy), + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: compute lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateEC2: no compute price found for %s in %s", attrs.InstanceType, region) + } + if len(products) > 1 { + slog.Warn("pricing: EC2 compute query returned multiple products; using first", + "instanceType", attrs.InstanceType, + "region", region, + "count", len(products), + ) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: parsing compute price: %w", err) + } + if unit != "Hrs" { + return 0, fmt.Errorf("EstimateEC2: expected compute unit Hrs, got %q", unit) + } + return hourly * HoursPerMonth, nil +} + +// lookupRootEBSPrice runs the Pricing API query for the root EBS volume +// and returns the monthly USD cost (GB-month price * size). +func lookupRootEBSPrice(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (float64, error) { + filters := map[string]string{ + "productFamily": "Storage", + "volumeApiName": attrs.RootBlockType, + "regionCode": region, + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: root EBS lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateEC2: no EBS price found for %s in %s", attrs.RootBlockType, region) + } + if len(products) > 1 { + slog.Warn("pricing: EC2 root EBS query returned multiple products; using first", + "volumeType", attrs.RootBlockType, + "region", region, + "count", len(products), + ) + } + gbMo, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateEC2: parsing root EBS price: %w", err) + } + if unit != "GB-Mo" { + return 0, fmt.Errorf("EstimateEC2: expected root EBS unit GB-Mo, got %q", unit) + } + return gbMo * float64(attrs.RootBlockSize), nil +} + +// mapTenancy translates Terraform's tenancy attribute to the AWS Pricing +// API's tenancy filter value. Terraform uses lowercase ("default", +// "dedicated", "host"); the Pricing API uses TitleCase ("Shared", +// "Dedicated", "Host"). The empty string maps to "Shared" because +// aws_instance defaults tenancy to "default" when omitted. +// +// Unknown values fall back to "Shared" with a warn log so an unexpected +// future tenancy doesn't silently mis-price. +func mapTenancy(tenancy string) string { + switch tenancy { + case "", "default": + return "Shared" + case "dedicated": + return "Dedicated" + case "host": + return "Host" + default: + slog.Warn("pricing: unknown EC2 tenancy, defaulting to Shared", + "tenancy", tenancy, + ) + return "Shared" + } +} diff --git a/internal/pricing/ec2_test.go b/internal/pricing/ec2_test.go new file mode 100644 index 0000000..c10a54c --- /dev/null +++ b/internal/pricing/ec2_test.go @@ -0,0 +1,418 @@ +package pricing + +import ( + "context" + "errors" + "log/slog" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +// scriptedGetter is a productGetter that replays pre-programmed responses +// in call order. It records every call's serviceCode and a copy of the +// filters map so tests can assert exactly what was sent. Different from +// fakeProductGetter (cache_test.go) which always returns the same data — +// EC2 tests need different replies for the compute and EBS queries. +type scriptedGetter struct { + responses [][]string + errs []error + calls []scriptedCall +} + +type scriptedCall struct { + service string + filters map[string]string +} + +func (s *scriptedGetter) GetProducts(_ context.Context, service string, filters map[string]string) ([]string, error) { + idx := len(s.calls) + cp := make(map[string]string, len(filters)) + for k, v := range filters { + cp[k] = v + } + s.calls = append(s.calls, scriptedCall{service: service, filters: cp}) + + if idx < len(s.errs) && s.errs[idx] != nil { + return nil, s.errs[idx] + } + if idx >= len(s.responses) { + return nil, errors.New("scriptedGetter: no response programmed for this call") + } + return s.responses[idx], nil +} + +func TestEstimateEC2_WithRootBlock(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + + wantCompute := 0.0832 * HoursPerMonth // 60.736 + wantEBS := 0.08 * 50 // 4.0 + wantTotal := wantCompute + wantEBS + + if math.Abs(est.MonthlyUSD-wantTotal) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantTotal) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q, want USD", est.Currency) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(est.Breakdown) != 2 { + t.Fatalf("Breakdown len = %d, want 2", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" || math.Abs(est.Breakdown[0].MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("Breakdown[0] = %+v, want Compute=%v", est.Breakdown[0], wantCompute) + } + if est.Breakdown[1].Component != "RootEBS" || math.Abs(est.Breakdown[1].MonthlyUSD-wantEBS) > 1e-6 { + t.Errorf("Breakdown[1] = %+v, want RootEBS=%v", est.Breakdown[1], wantEBS) + } + + // Compute query filters + if len(src.calls) != 2 { + t.Fatalf("got %d API calls, want 2", len(src.calls)) + } + c := src.calls[0] + if c.service != "AmazonEC2" { + t.Errorf("compute call service = %q", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Compute Instance", + "instanceType": "t3.large", + "regionCode": "us-east-2", + "tenancy": "Shared", + "operatingSystem": "Linux", + "preInstalledSw": "NA", + "capacitystatus": "Used", + } { + if c.filters[k] != want { + t.Errorf("compute filter %s = %q, want %q", k, c.filters[k], want) + } + } + + // EBS query filters + e := src.calls[1] + if e.service != "AmazonEC2" { + t.Errorf("ebs call service = %q", e.service) + } + for k, want := range map[string]string{ + "productFamily": "Storage", + "volumeApiName": "gp3", + "regionCode": "us-east-2", + } { + if e.filters[k] != want { + t.Errorf("ebs filter %s = %q, want %q", k, e.filters[k], want) + } + } +} + +func TestEstimateEC2_NoRootBlock(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if len(src.calls) != 1 { + t.Errorf("got %d API calls, want 1 (no EBS lookup when RootBlockSize=0)", len(src.calls)) + } + if len(est.Breakdown) != 1 { + t.Fatalf("Breakdown len = %d, want 1", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" { + t.Errorf("Breakdown[0].Component = %q, want Compute", est.Breakdown[0].Component) + } + wantCompute := 0.0832 * HoursPerMonth + if math.Abs(est.MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantCompute) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "Root block device unknown") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing 'Root block device unknown' caveat: %v", est.Notes) + } +} + +func TestEstimateEC2_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateEC2(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v, want 'nil attrs' error", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls on nil attrs, got %d", len(src.calls)) + } +} + +func TestEstimateEC2_EmptyInstanceType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EC2Attributes{} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceType") { + t.Fatalf("err = %v, want 'empty InstanceType' error", err) + } +} + +func TestEstimateEC2_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v, want 'empty region' error", err) + } +} + +func TestEstimateEC2_NoComputeProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error when compute query returns 0 products") + } + if !strings.Contains(err.Error(), "no compute price found") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestEstimateEC2_MultipleComputeProductsUsesFirst(t *testing.T) { + first := loadFixture(t, "ec2_t3_large_us_east_2.json") + second := strings.Replace(first, `"USD": "0.0832"`, `"USD": "9.99"`, 1) + + src := &scriptedGetter{responses: [][]string{{first, second}}} + logs := captureLogs(t, slog.LevelWarn) + + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + wantCompute := 0.0832 * HoursPerMonth + if math.Abs(est.MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v (must use first product)", est.MonthlyUSD, wantCompute) + } + if !strings.Contains(logs.String(), "multiple products") { + t.Errorf("expected warn log about multiple products, got: %s", logs.String()) + } +} + +func TestEstimateEC2_BadComputeUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "ec2_t3_large_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected compute unit Hrs") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestEstimateEC2_BadEBSUnit(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + bad := strings.Replace( + loadFixture(t, "ec2_gp3_us_east_2.json"), + `"unit": "GB-Mo"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{compute}, {bad}}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected root EBS unit GB-Mo") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestEstimateEC2_TenancyDedicated(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "dedicated", + } + if _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if got := src.calls[0].filters["tenancy"]; got != "Dedicated" { + t.Errorf("tenancy filter = %q, want Dedicated", got) + } +} + +func TestEstimateEC2_TenancyHost(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "host", + } + if _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if got := src.calls[0].filters["tenancy"]; got != "Host" { + t.Errorf("tenancy filter = %q, want Host", got) + } +} + +func TestEstimateEC2_ConfidenceAlwaysLow(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (OS is always assumed)", est.Confidence) + } + foundOS := false + for _, n := range est.Notes { + if strings.Contains(n, "Linux") { + foundOS = true + break + } + } + if !foundOS { + t.Errorf("Notes missing OS=Linux assumption: %v", est.Notes) + } +} + +func TestEstimateEC2_ComputeError(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEC2_EBSError(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + innerErr := errors.New("RateExceeded") + src := &scriptedGetter{ + responses: [][]string{{compute}, nil}, + errs: []error{nil, innerErr}, + } + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEC2_NoEBSProducts(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, nil}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no EBS price found") { + t.Fatalf("err = %v, want 'no EBS price found' error", err) + } +} + +func TestEstimateEC2_BadComputeJSON(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{"not-json{{{"}}} + attrs := &aws.EC2Attributes{InstanceType: "t3.large"} + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "parsing compute price") { + t.Fatalf("err = %v, want 'parsing compute price' error", err) + } +} + +func TestEstimateEC2_BadEBSJSON(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {"not-json{{{"}}} + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + RootBlockSize: 50, + RootBlockType: "gp3", + } + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "parsing root EBS price") { + t.Fatalf("err = %v, want 'parsing root EBS price' error", err) + } +} + +func TestMapTenancy(t *testing.T) { + cases := []struct { + in, want string + }{ + {"default", "Shared"}, + {"", "Shared"}, + {"dedicated", "Dedicated"}, + {"host", "Host"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := mapTenancy(c.in); got != c.want { + t.Errorf("mapTenancy(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestMapTenancy_UnknownDefaultsToShared(t *testing.T) { + logs := captureLogs(t, slog.LevelWarn) + got := mapTenancy("on-prem-bring-your-own") + if got != "Shared" { + t.Errorf("mapTenancy = %q, want Shared", got) + } + if !strings.Contains(logs.String(), "unknown EC2 tenancy") { + t.Errorf("expected warn log on unknown tenancy, got: %s", logs.String()) + } +} diff --git a/internal/pricing/parse.go b/internal/pricing/parse.go new file mode 100644 index 0000000..e15af15 --- /dev/null +++ b/internal/pricing/parse.go @@ -0,0 +1,82 @@ +package pricing + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" +) + +// parseOnDemandPriceUSD extracts the per-unit OnDemand USD price from a +// single AWS Pricing API product JSON document — one element of the +// PriceList slice returned by GetProducts. +// +// The product payload nests pricing under +// +// terms.OnDemand..priceDimensions..pricePerUnit.USD +// +// where and are AWS-internal opaque strings. EC2 compute, +// EBS storage, and RDS instances each carry exactly one OnDemand SKU +// with a single price dimension, so this helper picks the first entry of +// each map sorted by key. Sorting matters because Go map iteration order +// is randomised: we want byte-identical results across runs. +// +// Tiered services (S3 storage classes, Lambda invocations) expose +// multiple price dimensions in a single SKU and need a different helper +// — they are out of scope here. +// +// The unit string ("Hrs", "GB-Mo", ...) is returned alongside the price +// so callers can confirm the product they pulled matches the cost +// component they expected (compute → "Hrs", storage → "GB-Mo"). A +// mismatch usually means the filters were too loose and selected the +// wrong product family. +// +// An error is returned (with context) when the JSON does not parse, +// terms.OnDemand is missing or empty, the priceDimensions map is missing +// or empty, the USD entry is absent, or the price string fails to parse +// as a float. +func parseOnDemandPriceUSD(productJSON string) (price float64, unit string, err error) { + var doc struct { + Terms struct { + OnDemand map[string]struct { + PriceDimensions map[string]struct { + Unit string `json:"unit"` + PricePerUnit map[string]string `json:"pricePerUnit"` + } `json:"priceDimensions"` + } `json:"OnDemand"` + } `json:"terms"` + } + if err := json.Unmarshal([]byte(productJSON), &doc); err != nil { + return 0, "", fmt.Errorf("pricing: parsing product JSON: %w", err) + } + + if len(doc.Terms.OnDemand) == 0 { + return 0, "", fmt.Errorf("pricing: no OnDemand pricing in product") + } + skuKeys := make([]string, 0, len(doc.Terms.OnDemand)) + for k := range doc.Terms.OnDemand { + skuKeys = append(skuKeys, k) + } + sort.Strings(skuKeys) + sku := doc.Terms.OnDemand[skuKeys[0]] + + if len(sku.PriceDimensions) == 0 { + return 0, "", fmt.Errorf("pricing: OnDemand SKU has no priceDimensions") + } + dimKeys := make([]string, 0, len(sku.PriceDimensions)) + for k := range sku.PriceDimensions { + dimKeys = append(dimKeys, k) + } + sort.Strings(dimKeys) + dim := sku.PriceDimensions[dimKeys[0]] + + usdStr, ok := dim.PricePerUnit["USD"] + if !ok { + return 0, "", fmt.Errorf("pricing: priceDimension has no USD price") + } + usd, err := strconv.ParseFloat(usdStr, 64) + if err != nil { + return 0, "", fmt.Errorf("pricing: parsing USD price %q: %w", usdStr, err) + } + return usd, dim.Unit, nil +} diff --git a/internal/pricing/parse_test.go b/internal/pricing/parse_test.go new file mode 100644 index 0000000..d2b9379 --- /dev/null +++ b/internal/pricing/parse_test.go @@ -0,0 +1,201 @@ +package pricing + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" +) + +// loadFixture returns the contents of a JSON file under testdata/. Panics +// (via t.Fatal) on any IO error so the test fails loudly with a path the +// developer can investigate. +func loadFixture(t *testing.T, name string) string { + t.Helper() + path := filepath.Join("testdata", name) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("loading fixture %q: %v", path, err) + } + return string(raw) +} + +func TestParseOnDemandPriceUSD_HappyPathCompute(t *testing.T) { + body := loadFixture(t, "ec2_t3_large_us_east_2.json") + price, unit, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.0832) > 1e-9 { + t.Errorf("price = %v, want 0.0832", price) + } + if unit != "Hrs" { + t.Errorf("unit = %q, want Hrs", unit) + } +} + +func TestParseOnDemandPriceUSD_HappyPathStorage(t *testing.T) { + body := loadFixture(t, "ec2_gp3_us_east_2.json") + price, unit, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.08) > 1e-9 { + t.Errorf("price = %v, want 0.08", price) + } + if unit != "GB-Mo" { + t.Errorf("unit = %q, want GB-Mo", unit) + } +} + +func TestParseOnDemandPriceUSD_MultipleSKUsPicksFirstSorted(t *testing.T) { + // Two SKUs: "ZZZ.JRTC" (price 0.99) and "AAA.JRTC" (price 0.01). + // Sorted ascending the AAA SKU wins regardless of map iteration order. + body := `{ + "terms": { + "OnDemand": { + "ZZZ.JRTC": { + "priceDimensions": { + "ZZZ.JRTC.D1": { + "unit": "Hrs", + "pricePerUnit": {"USD": "0.99"} + } + } + }, + "AAA.JRTC": { + "priceDimensions": { + "AAA.JRTC.D1": { + "unit": "Hrs", + "pricePerUnit": {"USD": "0.01"} + } + } + } + } + } + }` + price, _, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-0.01) > 1e-9 { + t.Errorf("price = %v, want 0.01 (sorted-first SKU)", price) + } +} + +func TestParseOnDemandPriceUSD_MultiplePriceDimensionsPicksFirstSorted(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S1.T1": { + "priceDimensions": { + "S1.T1.ZZZ": {"unit": "Hrs", "pricePerUnit": {"USD": "9.00"}}, + "S1.T1.AAA": {"unit": "Hrs", "pricePerUnit": {"USD": "1.00"}} + } + } + } + } + }` + price, _, err := parseOnDemandPriceUSD(body) + if err != nil { + t.Fatalf("parseOnDemandPriceUSD: %v", err) + } + if math.Abs(price-1.00) > 1e-9 { + t.Errorf("price = %v, want 1.00 (sorted-first dimension)", price) + } +} + +func TestParseOnDemandPriceUSD_InvalidJSON(t *testing.T) { + _, _, err := parseOnDemandPriceUSD("not-json{{{") + if err == nil { + t.Fatal("expected error on invalid JSON") + } + if !strings.Contains(err.Error(), "parsing product JSON") { + t.Errorf("error missing context: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingTerms(t *testing.T) { + _, _, err := parseOnDemandPriceUSD(`{"product": {}}`) + if err == nil { + t.Fatal("expected error when terms is missing") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingOnDemand(t *testing.T) { + body := `{"terms": {"Reserved": {"X.Y": {}}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when OnDemand block is missing") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_EmptyOnDemand(t *testing.T) { + body := `{"terms": {"OnDemand": {}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error on empty OnDemand block") + } + if !strings.Contains(err.Error(), "no OnDemand pricing") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingPriceDimensions(t *testing.T) { + body := `{"terms": {"OnDemand": {"S.T": {}}}}` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when priceDimensions missing") + } + if !strings.Contains(err.Error(), "priceDimensions") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_MissingUSD(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S.T": { + "priceDimensions": { + "S.T.D": {"unit": "Hrs", "pricePerUnit": {"CNY": "0.10"}} + } + } + } + } + }` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error when USD price missing") + } + if !strings.Contains(err.Error(), "no USD price") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestParseOnDemandPriceUSD_NonNumericPrice(t *testing.T) { + body := `{ + "terms": { + "OnDemand": { + "S.T": { + "priceDimensions": { + "S.T.D": {"unit": "Hrs", "pricePerUnit": {"USD": "not-a-number"}} + } + } + } + } + }` + _, _, err := parseOnDemandPriceUSD(body) + if err == nil { + t.Fatal("expected error on non-numeric price") + } + if !strings.Contains(err.Error(), "parsing USD price") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/pricing/testdata/ec2_gp3_us_east_2.json b/internal/pricing/testdata/ec2_gp3_us_east_2.json new file mode 100644 index 0000000..088cfc8 --- /dev/null +++ b/internal/pricing/testdata/ec2_gp3_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Storage", + "attributes": { + "volumeApiName": "gp3", + "regionCode": "us-east-2", + "storageMedia": "SSD-backed", + "volumeType": "General Purpose" + }, + "sku": "ABDXKE9QMAJYGGHA" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "ABDXKE9QMAJYGGHA.JRTCKXETXF": { + "priceDimensions": { + "ABDXKE9QMAJYGGHA.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.08 per GB-month of General Purpose SSD (gp3) provisioned storage - US East (Ohio)", + "appliesTo": [], + "rateCode": "ABDXKE9QMAJYGGHA.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.08"} + } + }, + "sku": "ABDXKE9QMAJYGGHA", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/ec2_t3_large_us_east_2.json b/internal/pricing/testdata/ec2_t3_large_us_east_2.json new file mode 100644 index 0000000..982d0cb --- /dev/null +++ b/internal/pricing/testdata/ec2_t3_large_us_east_2.json @@ -0,0 +1,40 @@ +{ + "product": { + "productFamily": "Compute Instance", + "attributes": { + "instanceType": "t3.large", + "regionCode": "us-east-2", + "operatingSystem": "Linux", + "tenancy": "Shared", + "preInstalledSw": "NA", + "capacitystatus": "Used", + "vcpu": "2", + "memory": "8 GiB" + }, + "sku": "JX5VPQ7TKDF8MWZE" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "JX5VPQ7TKDF8MWZE.JRTCKXETXF": { + "priceDimensions": { + "JX5VPQ7TKDF8MWZE.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.0832 per On Demand Linux t3.large Instance Hour", + "appliesTo": [], + "rateCode": "JX5VPQ7TKDF8MWZE.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.0832"} + } + }, + "sku": "JX5VPQ7TKDF8MWZE", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/types.go b/internal/pricing/types.go new file mode 100644 index 0000000..ed44c6e --- /dev/null +++ b/internal/pricing/types.go @@ -0,0 +1,44 @@ +package pricing + +// Estimate is the result of a cost estimation for a single resource. +// +// MonthlyUSD is the sum of every Breakdown line item, expressed in USD +// per AWS-standard 730-hour month (see HoursPerMonth). Currency is always +// "USD" today; the AWS Pricing API supports CNY for AWS China but we do +// not — adding it would multiply the surface area of every mapper. +// +// Confidence reports how many defaults the mapper had to assume. Low +// estimates should reach the user with a "may differ from real bill" +// caveat; the assumptions themselves are listed in Notes for transparency +// rather than buried in the Confidence value. +type Estimate struct { + MonthlyUSD float64 + Currency string + Breakdown []LineItem + Confidence Confidence + Notes []string +} + +// LineItem is one component of an Estimate's total cost. The components +// of an EC2 Estimate are "Compute" and (optionally) "RootEBS"; future +// mappers will introduce their own component names. +type LineItem struct { + Component string + MonthlyUSD float64 +} + +// Confidence indicates how reliable an Estimate is. +// +// - ConfidenceHigh: no defaults applied, all relevant attributes +// were present on the resource. +// - ConfidenceMedium: minor defaults applied (e.g., AZ unknown so +// region-level pricing was used). +// - ConfidenceLow: one or more strong assumptions were made (e.g., +// OS=Linux assumed because Terraform plans don't carry OS). +type Confidence string + +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" +) From b198f8e13b11987112b2c5b3b3b5d4bf13ee32fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 12:28:41 -0400 Subject: [PATCH 07/21] feat: implement EBS pricing lookup and estimation for various volume types --- internal/pricing/ebs.go | 141 ++++++ internal/pricing/ebs_test.go | 255 ++++++++++ internal/pricing/ec2.go | 37 +- internal/pricing/ec2_test.go | 6 +- internal/pricing/rds.go | 227 +++++++++ internal/pricing/rds_test.go | 436 ++++++++++++++++++ .../pricing/testdata/ebs_io1_us_east_2.json | 36 ++ .../rds_postgres_db_t3_medium_us_east_2.json | 39 ++ .../testdata/rds_storage_gp2_us_east_2.json | 36 ++ 9 files changed, 1176 insertions(+), 37 deletions(-) create mode 100644 internal/pricing/ebs.go create mode 100644 internal/pricing/ebs_test.go create mode 100644 internal/pricing/rds.go create mode 100644 internal/pricing/rds_test.go create mode 100644 internal/pricing/testdata/ebs_io1_us_east_2.json create mode 100644 internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json create mode 100644 internal/pricing/testdata/rds_storage_gp2_us_east_2.json diff --git a/internal/pricing/ebs.go b/internal/pricing/ebs.go new file mode 100644 index 0000000..c82d42d --- /dev/null +++ b/internal/pricing/ebs.go @@ -0,0 +1,141 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// lookupEBSStoragePrice queries the Pricing API for the per-GB-month USD +// rate of an EBS volume type in a given region. Used by both EstimateEBS +// (standalone volumes) and EstimateEC2 (root block devices), so it is +// kept generic — the caller multiplies by size and adds context-specific +// notes. +// +// volumeType is the Terraform value: "gp2", "gp3", "io1", "io2", "st1", +// "sc1", or "standard". The Pricing API's volumeApiName field currently +// uses the same strings, but mapping through mapEBSVolumeAPIName means a +// future divergence (or a typo guard) lives in exactly one place. +// +// Returns an error for empty/unknown volume types, API failures, products +// that come back empty, parse failures, or any unit other than "GB-Mo" +// — that last case usually indicates the filters were too loose and the +// query matched a non-storage product family. +func lookupEBSStoragePrice(ctx context.Context, src productGetter, volumeType, region string) (float64, error) { + apiName, err := mapEBSVolumeAPIName(volumeType) + if err != nil { + return 0, err + } + filters := map[string]string{ + "productFamily": "Storage", + "volumeApiName": apiName, + "regionCode": region, + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return 0, fmt.Errorf("lookupEBSStoragePrice: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("lookupEBSStoragePrice: no EBS price found for %s in %s", volumeType, region) + } + if len(products) > 1 { + slog.Warn("pricing: EBS storage query returned multiple products; using first", + "volumeType", volumeType, + "region", region, + "count", len(products), + ) + } + gbMo, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("lookupEBSStoragePrice: parsing EBS price: %w", err) + } + if unit != "GB-Mo" { + return 0, fmt.Errorf("lookupEBSStoragePrice: expected EBS unit GB-Mo, got %q", unit) + } + return gbMo, nil +} + +// mapEBSVolumeAPIName validates a Terraform EBS volume type and returns +// the Pricing API's volumeApiName filter value. Today the strings line +// up one-to-one, but isolating the mapping rejects typos up front (with +// a clear error) instead of via "no products found" pages later. +func mapEBSVolumeAPIName(tfType string) (string, error) { + switch tfType { + case "gp2", "gp3", "io1", "io2", "st1", "sc1", "standard": + return tfType, nil + case "": + return "", fmt.Errorf("lookupEBSStoragePrice: empty volume type") + default: + return "", fmt.Errorf("lookupEBSStoragePrice: unknown volume type %q", tfType) + } +} + +// EstimateEBS calculates the monthly cost of a standalone aws_ebs_volume. +// It charges the GB-month rate for the volume's type and size. +// +// Charges NOT included in the estimate: +// +// - IOPS-month billing for gp3 above the 3000 IOPS that ship by default +// - Throughput-month billing for gp3 above the 125 MB/s default +// - IOPS-month billing for io1/io2 (a separate Pricing API +// productFamily that's out of scope for this milestone) +// - Snapshot storage and data-transfer charges +// +// Confidence rules: +// +// - gp2, st1, sc1, standard: ConfidenceMedium — flat GB-month price, +// no IOPS/throughput add-ons exist for these types. +// - gp3 at default IOPS/throughput: ConfidenceMedium — the included +// defaults cover most workloads. +// - gp3 with Iops > 3000 or Throughput > 125: ConfidenceLow — the +// missing add-on charges are material. +// - io1, io2: ConfidenceLow — IOPS billing is the dominant cost for +// these types and we are not modelling it. +// +// Returns an error for nil attrs, empty region, empty Type, Size <= 0, +// unknown volume types, API failures, missing products, or unit +// mismatches. +func EstimateEBS(ctx context.Context, src productGetter, attrs *aws.EBSAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateEBS: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateEBS: nil attrs") + } + if attrs.Type == "" { + return Estimate{}, fmt.Errorf("EstimateEBS: empty Type") + } + if attrs.Size <= 0 { + return Estimate{}, fmt.Errorf("EstimateEBS: Size must be > 0, got %d", attrs.Size) + } + + gbMo, err := lookupEBSStoragePrice(ctx, src, attrs.Type, region) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateEBS: %w", err) + } + storageCost := gbMo * float64(attrs.Size) + + notes := []string{ + "IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s)", + } + confidence := ConfidenceMedium + switch attrs.Type { + case "io1", "io2": + notes = append(notes, "io1/io2 IOPS billing is separate and not included in this estimate") + confidence = ConfidenceLow + case "gp3": + if attrs.Iops > 3000 || attrs.Throughput > 125 { + confidence = ConfidenceLow + } + } + + return Estimate{ + MonthlyUSD: storageCost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Storage", MonthlyUSD: storageCost}}, + Confidence: confidence, + Notes: notes, + }, nil +} diff --git a/internal/pricing/ebs_test.go b/internal/pricing/ebs_test.go new file mode 100644 index 0000000..e8d6d44 --- /dev/null +++ b/internal/pricing/ebs_test.go @@ -0,0 +1,255 @@ +package pricing + +import ( + "context" + "errors" + "log/slog" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +// minimalGBMoProduct is a synthetic but well-formed Pricing API product +// JSON for tests that don't care about the real values, only that the +// parser succeeds and the unit is "GB-Mo". +const minimalGBMoProduct = `{"terms":{"OnDemand":{"S.T":{"priceDimensions":{"S.T.D":{"unit":"GB-Mo","pricePerUnit":{"USD":"0.10"}}}}}}}` + +func TestLookupEBSStoragePrice_AllSupportedTypes(t *testing.T) { + types := []string{"gp2", "gp3", "io1", "io2", "st1", "sc1", "standard"} + for _, vt := range types { + t.Run(vt, func(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{minimalGBMoProduct}}} + price, err := lookupEBSStoragePrice(context.Background(), src, vt, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if math.Abs(price-0.10) > 1e-9 { + t.Errorf("price = %v, want 0.10", price) + } + if len(src.calls) != 1 { + t.Fatalf("calls = %d, want 1", len(src.calls)) + } + if got := src.calls[0].service; got != "AmazonEC2" { + t.Errorf("service = %q, want AmazonEC2", got) + } + if got := src.calls[0].filters["volumeApiName"]; got != vt { + t.Errorf("volumeApiName = %q, want %q", got, vt) + } + if got := src.calls[0].filters["productFamily"]; got != "Storage" { + t.Errorf("productFamily = %q, want Storage", got) + } + if got := src.calls[0].filters["regionCode"]; got != "us-east-2" { + t.Errorf("regionCode = %q", got) + } + }) + } +} + +func TestLookupEBSStoragePrice_UnknownType(t *testing.T) { + src := &scriptedGetter{} + _, err := lookupEBSStoragePrice(context.Background(), src, "weird", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown volume type") { + t.Fatalf("err = %v, want unknown-type error", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls on unknown type, got %d", len(src.calls)) + } +} + +func TestLookupEBSStoragePrice_EmptyType(t *testing.T) { + src := &scriptedGetter{} + _, err := lookupEBSStoragePrice(context.Background(), src, "", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty volume type") { + t.Fatalf("err = %v, want empty-type error", err) + } +} + +func TestLookupEBSStoragePrice_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no EBS price found") { + t.Fatalf("err = %v, want 'no EBS price found' error", err) + } +} + +func TestLookupEBSStoragePrice_MultipleProductsUsesFirst(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + second := strings.Replace(gp3, `"USD": "0.08"`, `"USD": "9.99"`, 1) + src := &scriptedGetter{responses: [][]string{{gp3, second}}} + logs := captureLogs(t, slog.LevelWarn) + + price, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if math.Abs(price-0.08) > 1e-9 { + t.Errorf("price = %v, want 0.08 (first product)", price) + } + if !strings.Contains(logs.String(), "multiple products") { + t.Errorf("expected warn log, got: %s", logs.String()) + } +} + +func TestLookupEBSStoragePrice_BadUnit(t *testing.T) { + body := strings.Replace(minimalGBMoProduct, `"unit":"GB-Mo"`, `"unit":"Hrs"`, 1) + src := &scriptedGetter{responses: [][]string{{body}}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected EBS unit GB-Mo") { + t.Fatalf("err = %v, want unit-mismatch error", err) + } +} + +func TestLookupEBSStoragePrice_PropagatesAPIError(t *testing.T) { + innerErr := errors.New("RateExceeded") + src := &scriptedGetter{errs: []error{innerErr}} + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateEBS_GP3DefaultIOPS_ConfidenceMedium(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + + attrs := &aws.EBSAttributes{ + Type: "gp3", + Size: 100, + } + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + want := 0.08 * 100 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } + if len(est.Breakdown) != 1 || est.Breakdown[0].Component != "Storage" { + t.Errorf("Breakdown = %+v", est.Breakdown) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } +} + +func TestEstimateEBS_GP3HighIOPS_ConfidenceLow(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 100, Iops: 5000} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Iops > 3000)", est.Confidence) + } +} + +func TestEstimateEBS_GP3HighThroughput_ConfidenceLow(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 100, Throughput: 250} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Throughput > 125)", est.Confidence) + } +} + +func TestEstimateEBS_IO1_ConfidenceLowWithNote(t *testing.T) { + io1 := loadFixture(t, "ebs_io1_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{io1}}} + + attrs := &aws.EBSAttributes{Type: "io1", Size: 200, Iops: 4000} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + want := 0.125 * 200 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + foundIOPS := false + for _, n := range est.Notes { + if strings.Contains(n, "io1/io2 IOPS billing") { + foundIOPS = true + break + } + } + if !foundIOPS { + t.Errorf("Notes missing io1/io2 IOPS caveat: %v", est.Notes) + } + // volumeApiName filter should be "io1" + if got := src.calls[0].filters["volumeApiName"]; got != "io1" { + t.Errorf("volumeApiName = %q, want io1", got) + } +} + +func TestEstimateEBS_GP2_ConfidenceMedium(t *testing.T) { + src := &scriptedGetter{responses: [][]string{{minimalGBMoProduct}}} + attrs := &aws.EBSAttributes{Type: "gp2", Size: 50} + est, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } +} + +func TestEstimateEBS_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateEBS(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "gp3", Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_EmptyType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Type") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_SizeZero(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "gp3"} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "Size must be > 0") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateEBS_UnknownType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.EBSAttributes{Type: "weird", Size: 50} + _, err := EstimateEBS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown volume type") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/ec2.go b/internal/pricing/ec2.go index 3599b32..edbf0f5 100644 --- a/internal/pricing/ec2.go +++ b/internal/pricing/ec2.go @@ -71,10 +71,11 @@ func EstimateEC2(ctx context.Context, src productGetter, attrs *aws.EC2Attribute total := compute if attrs.RootBlockSize > 0 { - rootEBS, err := lookupRootEBSPrice(ctx, src, attrs, region) + gbMo, err := lookupEBSStoragePrice(ctx, src, attrs.RootBlockType, region) if err != nil { - return Estimate{}, err + return Estimate{}, fmt.Errorf("EstimateEC2: root EBS: %w", err) } + rootEBS := gbMo * float64(attrs.RootBlockSize) breakdown = append(breakdown, LineItem{Component: "RootEBS", MonthlyUSD: rootEBS}) total += rootEBS } else { @@ -126,38 +127,6 @@ func lookupComputePrice(ctx context.Context, src productGetter, attrs *aws.EC2At return hourly * HoursPerMonth, nil } -// lookupRootEBSPrice runs the Pricing API query for the root EBS volume -// and returns the monthly USD cost (GB-month price * size). -func lookupRootEBSPrice(ctx context.Context, src productGetter, attrs *aws.EC2Attributes, region string) (float64, error) { - filters := map[string]string{ - "productFamily": "Storage", - "volumeApiName": attrs.RootBlockType, - "regionCode": region, - } - products, err := src.GetProducts(ctx, "AmazonEC2", filters) - if err != nil { - return 0, fmt.Errorf("EstimateEC2: root EBS lookup: %w", err) - } - if len(products) == 0 { - return 0, fmt.Errorf("EstimateEC2: no EBS price found for %s in %s", attrs.RootBlockType, region) - } - if len(products) > 1 { - slog.Warn("pricing: EC2 root EBS query returned multiple products; using first", - "volumeType", attrs.RootBlockType, - "region", region, - "count", len(products), - ) - } - gbMo, unit, err := parseOnDemandPriceUSD(products[0]) - if err != nil { - return 0, fmt.Errorf("EstimateEC2: parsing root EBS price: %w", err) - } - if unit != "GB-Mo" { - return 0, fmt.Errorf("EstimateEC2: expected root EBS unit GB-Mo, got %q", unit) - } - return gbMo * float64(attrs.RootBlockSize), nil -} - // mapTenancy translates Terraform's tenancy attribute to the AWS Pricing // API's tenancy filter value. Terraform uses lowercase ("default", // "dedicated", "host"); the Pricing API uses TitleCase ("Shared", diff --git a/internal/pricing/ec2_test.go b/internal/pricing/ec2_test.go index c10a54c..f5ece8b 100644 --- a/internal/pricing/ec2_test.go +++ b/internal/pricing/ec2_test.go @@ -250,7 +250,7 @@ func TestEstimateEC2_BadEBSUnit(t *testing.T) { RootBlockType: "gp3", } _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") - if err == nil || !strings.Contains(err.Error(), "expected root EBS unit GB-Mo") { + if err == nil || !strings.Contains(err.Error(), "expected EBS unit GB-Mo") { t.Fatalf("err = %v, want unit-mismatch error", err) } } @@ -383,8 +383,8 @@ func TestEstimateEC2_BadEBSJSON(t *testing.T) { RootBlockType: "gp3", } _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") - if err == nil || !strings.Contains(err.Error(), "parsing root EBS price") { - t.Fatalf("err = %v, want 'parsing root EBS price' error", err) + if err == nil || !strings.Contains(err.Error(), "parsing EBS price") { + t.Fatalf("err = %v, want 'parsing EBS price' error", err) } } diff --git a/internal/pricing/rds.go b/internal/pricing/rds.go new file mode 100644 index 0000000..0429b97 --- /dev/null +++ b/internal/pricing/rds.go @@ -0,0 +1,227 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "CloudOracle/internal/iac/aws" +) + +// EstimateRDS calculates the monthly cost of an RDS database instance, +// covering both compute (instance hours * 730) and database storage +// (GB-month * AllocatedStorage). +// +// Aurora engines (aurora, aurora-mysql, aurora-postgresql) are NOT +// supported here. Aurora's per-second compute and storage I/O billing +// uses a different Pricing API shape and lives behind aws_rds_cluster / +// aws_rds_cluster_instance — see EstimateRDSClusterInstance (Hito 13.5). +// EstimateRDS returns an error if attrs.Engine starts with "aurora". +// +// Supported engines are postgres, mysql, mariadb. Commercial engines +// (oracle*, sqlserver*) carry license-model branching that is out of +// scope for this milestone. +// +// Assumptions made by this function (each lowers Confidence): +// +// 1. licenseModel = "No license required". Valid for the three +// supported OSS engines, where AWS does not charge a license fee. +// 2. Multi-AZ doubles compute and storage cost. AWS encodes this in +// the deploymentOption filter rather than in our math: passing +// "Multi-AZ" yields a per-hour rate that is already roughly 2x the +// Single-AZ rate, and the same applies to GB-month storage. +// 3. IOPS-month billing for io1/io2 storage is NOT included. Only the +// GB-month base rate is charged. A note is appended when storage +// type is io1/io2 so the omission is visible to users. +// +// Returns an error for nil attrs, empty region/Engine/InstanceClass, +// AllocatedStorage <= 0, Aurora engines, unsupported engines, unknown +// storage types, API failures, missing products, or unit mismatches. +func EstimateRDS(ctx context.Context, src productGetter, attrs *aws.RDSAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateRDS: nil attrs") + } + if attrs.Engine == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty Engine") + } + if strings.HasPrefix(attrs.Engine, "aurora") { + return Estimate{}, fmt.Errorf("EstimateRDS: Aurora engine %q must be priced via EstimateRDSClusterInstance", attrs.Engine) + } + if attrs.InstanceClass == "" { + return Estimate{}, fmt.Errorf("EstimateRDS: empty InstanceClass") + } + if attrs.AllocatedStorage <= 0 { + return Estimate{}, fmt.Errorf("EstimateRDS: AllocatedStorage must be > 0, got %d", attrs.AllocatedStorage) + } + + dbEngine, err := mapEngine(attrs.Engine) + if err != nil { + return Estimate{}, err + } + storageVolType, err := mapRDSStorageType(attrs.StorageType) + if err != nil { + return Estimate{}, err + } + deployment := mapDeploymentOption(attrs.MultiAZ) + + compute, err := lookupRDSComputePrice(ctx, src, attrs.InstanceClass, dbEngine, deployment, region) + if err != nil { + return Estimate{}, err + } + storage, err := lookupRDSStoragePrice(ctx, src, storageVolType, deployment, region, attrs.AllocatedStorage) + if err != nil { + return Estimate{}, err + } + + notes := []string{"License: No license required (postgres/mysql/mariadb)"} + if attrs.MultiAZ { + notes = append(notes, "Multi-AZ deployment (2x base price)") + } + if attrs.StorageType == "io1" || attrs.StorageType == "io2" { + notes = append(notes, "io1/io2 IOPS-month billing not included in estimate") + } + + return Estimate{ + MonthlyUSD: compute + storage, + Currency: "USD", + Breakdown: []LineItem{ + {Component: "Compute", MonthlyUSD: compute}, + {Component: "Storage", MonthlyUSD: storage}, + }, + Confidence: ConfidenceLow, + Notes: notes, + }, nil +} + +// lookupRDSComputePrice runs the RDS Pricing API query for a database +// instance and returns the monthly USD cost (hourly price * 730). The +// licenseModel filter is hard-coded to "No license required" because +// EstimateRDS only accepts engines for which that is the correct value. +func lookupRDSComputePrice(ctx context.Context, src productGetter, instanceClass, dbEngine, deployment, region string) (float64, error) { + filters := map[string]string{ + "productFamily": "Database Instance", + "instanceType": instanceClass, + "databaseEngine": dbEngine, + "deploymentOption": deployment, + "regionCode": region, + "licenseModel": "No license required", + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: compute lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateRDS: no compute price found for %s/%s/%s in %s", instanceClass, dbEngine, deployment, region) + } + if len(products) > 1 { + slog.Warn("pricing: RDS compute query returned multiple products; using first", + "instanceClass", instanceClass, + "engine", dbEngine, + "deployment", deployment, + "region", region, + "count", len(products), + ) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: parsing compute price: %w", err) + } + if unit != "Hrs" { + return 0, fmt.Errorf("EstimateRDS: expected compute unit Hrs, got %q", unit) + } + return hourly * HoursPerMonth, nil +} + +// lookupRDSStoragePrice runs the RDS Pricing API query for the database +// storage volume and returns the monthly USD cost (GB-month * size). +// Note that RDS uses a different filter vocabulary than EC2/EBS: the +// filter name is volumeType (not volumeApiName) and the values are the +// long-form names produced by mapRDSStorageType. +func lookupRDSStoragePrice(ctx context.Context, src productGetter, storageVolType, deployment, region string, sizeGB int) (float64, error) { + filters := map[string]string{ + "productFamily": "Database Storage", + "volumeType": storageVolType, + "deploymentOption": deployment, + "regionCode": region, + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: storage lookup: %w", err) + } + if len(products) == 0 { + return 0, fmt.Errorf("EstimateRDS: no storage price found for %s/%s in %s", storageVolType, deployment, region) + } + if len(products) > 1 { + slog.Warn("pricing: RDS storage query returned multiple products; using first", + "volumeType", storageVolType, + "deployment", deployment, + "region", region, + "count", len(products), + ) + } + gbMo, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return 0, fmt.Errorf("EstimateRDS: parsing storage price: %w", err) + } + if unit != "GB-Mo" { + return 0, fmt.Errorf("EstimateRDS: expected storage unit GB-Mo, got %q", unit) + } + return gbMo * float64(sizeGB), nil +} + +// mapEngine converts a Terraform RDS engine value to the Pricing API's +// databaseEngine filter value. Only postgres, mysql, and mariadb are +// supported; any other engine (including Aurora variants) returns an +// error so the caller produces a precise message at the boundary. +func mapEngine(engine string) (string, error) { + switch engine { + case "postgres": + return "PostgreSQL", nil + case "mysql": + return "MySQL", nil + case "mariadb": + return "MariaDB", nil + default: + return "", fmt.Errorf("EstimateRDS: engine %q not supported in this version", engine) + } +} + +// mapRDSStorageType converts a Terraform RDS storage_type to the Pricing +// API's volumeType filter value. Note that RDS uses a different field +// (volumeType) and value vocabulary than EC2/EBS — they happen to model +// the same physical storage but the Pricing API surfaces them separately. +// +// Empty input defaults to "gp2", matching the AWS provider default for +// aws_db_instance.storage_type. Unknown input returns an error. +func mapRDSStorageType(tfType string) (string, error) { + switch tfType { + case "", "gp2": + return "General Purpose", nil + case "gp3": + return "General Purpose-GP3", nil + case "io1": + return "Provisioned IOPS", nil + case "io2": + return "Provisioned IOPS-IO2", nil + case "standard": + return "Magnetic", nil + default: + return "", fmt.Errorf("EstimateRDS: unknown storage type %q", tfType) + } +} + +// mapDeploymentOption converts a Multi-AZ bool to the Pricing API's +// deploymentOption filter value. Single-AZ is the cost baseline; +// Multi-AZ roughly doubles both compute and storage line items in +// AWS's catalogue (the doubling is encoded in the catalogue rates, not +// applied by us). +func mapDeploymentOption(multiAZ bool) string { + if multiAZ { + return "Multi-AZ" + } + return "Single-AZ" +} diff --git a/internal/pricing/rds_test.go b/internal/pricing/rds_test.go new file mode 100644 index 0000000..10ff8a3 --- /dev/null +++ b/internal/pricing/rds_test.go @@ -0,0 +1,436 @@ +package pricing + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateRDS_PostgresSingleAZGP2_HappyPath(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + + wantCompute := 0.082 * HoursPerMonth // 59.86 + wantStorage := 0.115 * 100 // 11.5 + wantTotal := wantCompute + wantStorage + + if math.Abs(est.MonthlyUSD-wantTotal) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, wantTotal) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(est.Breakdown) != 2 { + t.Fatalf("Breakdown len = %d, want 2", len(est.Breakdown)) + } + if est.Breakdown[0].Component != "Compute" || math.Abs(est.Breakdown[0].MonthlyUSD-wantCompute) > 1e-6 { + t.Errorf("Breakdown[0] = %+v, want Compute=%v", est.Breakdown[0], wantCompute) + } + if est.Breakdown[1].Component != "Storage" || math.Abs(est.Breakdown[1].MonthlyUSD-wantStorage) > 1e-6 { + t.Errorf("Breakdown[1] = %+v, want Storage=%v", est.Breakdown[1], wantStorage) + } + + // Compute query + if len(src.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(src.calls)) + } + c := src.calls[0] + if c.service != "AmazonRDS" { + t.Errorf("compute service = %q", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Instance", + "instanceType": "db.t3.medium", + "databaseEngine": "PostgreSQL", + "deploymentOption": "Single-AZ", + "regionCode": "us-east-2", + "licenseModel": "No license required", + } { + if c.filters[k] != want { + t.Errorf("compute filter %s = %q, want %q", k, c.filters[k], want) + } + } + + // Storage query + s := src.calls[1] + if s.service != "AmazonRDS" { + t.Errorf("storage service = %q", s.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Storage", + "volumeType": "General Purpose", + "deploymentOption": "Single-AZ", + "regionCode": "us-east-2", + } { + if s.filters[k] != want { + t.Errorf("storage filter %s = %q, want %q", k, s.filters[k], want) + } + } +} + +func TestEstimateRDS_MySQLMultiAZGP3_FilterCheck(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "mysql", + InstanceClass: "db.m5.large", + AllocatedStorage: 50, + StorageType: "gp3", + MultiAZ: true, + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + + if got := src.calls[0].filters["databaseEngine"]; got != "MySQL" { + t.Errorf("databaseEngine = %q, want MySQL", got) + } + if got := src.calls[0].filters["deploymentOption"]; got != "Multi-AZ" { + t.Errorf("deploymentOption = %q, want Multi-AZ", got) + } + if got := src.calls[1].filters["volumeType"]; got != "General Purpose-GP3" { + t.Errorf("storage volumeType = %q, want General Purpose-GP3", got) + } + if got := src.calls[1].filters["deploymentOption"]; got != "Multi-AZ" { + t.Errorf("storage deploymentOption = %q, want Multi-AZ", got) + } + + foundMAZ := false + for _, n := range est.Notes { + if strings.Contains(n, "Multi-AZ") { + foundMAZ = true + break + } + } + if !foundMAZ { + t.Errorf("Notes missing Multi-AZ caveat: %v", est.Notes) + } +} + +func TestEstimateRDS_MariaDB_EngineMapping(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "mariadb", + InstanceClass: "db.t3.medium", + AllocatedStorage: 20, + StorageType: "gp2", + } + if _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "MariaDB" { + t.Errorf("databaseEngine = %q, want MariaDB", got) + } +} + +func TestEstimateRDS_MultiAZWithIO1_BothNotes(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {storage}}} + + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "io1", + MultiAZ: true, + Iops: 1000, + } + est, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + foundMAZ, foundIOPS := false, false + for _, n := range est.Notes { + if strings.Contains(n, "Multi-AZ") { + foundMAZ = true + } + if strings.Contains(n, "io1/io2 IOPS-month") { + foundIOPS = true + } + } + if !foundMAZ { + t.Errorf("Notes missing Multi-AZ caveat: %v", est.Notes) + } + if !foundIOPS { + t.Errorf("Notes missing io1/io2 IOPS caveat: %v", est.Notes) + } + if got := src.calls[1].filters["volumeType"]; got != "Provisioned IOPS" { + t.Errorf("storage volumeType = %q, want Provisioned IOPS", got) + } +} + +func TestEstimateRDS_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateRDS(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{Engine: "postgres", InstanceClass: "db.t3.medium", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_AuroraEngineRejectedImmediately(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "aurora-postgresql", + InstanceClass: "db.r5.large", + AllocatedStorage: 100, + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "Aurora") { + t.Fatalf("err = %v, want Aurora rejection", err) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for Aurora, got %d", len(src.calls)) + } +} + +func TestEstimateRDS_OracleEngineNotSupported(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "oracle-ee", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "not supported in this version") { + t.Fatalf("err = %v, want not-supported error", err) + } +} + +func TestEstimateRDS_EmptyEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{InstanceClass: "db.t3.medium", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_EmptyInstanceClass(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{Engine: "postgres", AllocatedStorage: 100} + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceClass") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_AllocatedStorageZero(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "AllocatedStorage must be > 0") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_NoComputeProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no compute price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_NoStorageProducts(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, nil}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no storage price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_BadComputeUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected compute unit Hrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_BadStorageUnit(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + bad := strings.Replace( + loadFixture(t, "rds_storage_gp2_us_east_2.json"), + `"unit": "GB-Mo"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{compute}, {bad}}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected storage unit GB-Mo") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDS_PropagatesAPIError(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } +} + +func TestEstimateRDS_UnknownStorageType(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "weird", + } + _, err := EstimateRDS(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown storage type") { + t.Fatalf("err = %v", err) + } +} + +func TestMapRDSStorageType(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"", "General Purpose", false}, + {"gp2", "General Purpose", false}, + {"gp3", "General Purpose-GP3", false}, + {"io1", "Provisioned IOPS", false}, + {"io2", "Provisioned IOPS-IO2", false}, + {"standard", "Magnetic", false}, + {"weird", "", true}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := mapRDSStorageType(c.in) + if c.err { + if err == nil { + t.Errorf("expected error for input %q", c.in) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestMapDeploymentOption(t *testing.T) { + if got := mapDeploymentOption(false); got != "Single-AZ" { + t.Errorf("false -> %q, want Single-AZ", got) + } + if got := mapDeploymentOption(true); got != "Multi-AZ" { + t.Errorf("true -> %q, want Multi-AZ", got) + } +} + +func TestMapEngine(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"postgres", "PostgreSQL", false}, + {"mysql", "MySQL", false}, + {"mariadb", "MariaDB", false}, + {"oracle-ee", "", true}, + {"sqlserver-ee", "", true}, + {"", "", true}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + got, err := mapEngine(c.in) + if c.err { + if err == nil { + t.Errorf("expected error for %q", c.in) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/pricing/testdata/ebs_io1_us_east_2.json b/internal/pricing/testdata/ebs_io1_us_east_2.json new file mode 100644 index 0000000..814fda7 --- /dev/null +++ b/internal/pricing/testdata/ebs_io1_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Storage", + "attributes": { + "volumeApiName": "io1", + "regionCode": "us-east-2", + "storageMedia": "SSD-backed", + "volumeType": "Provisioned IOPS" + }, + "sku": "EBSIO1USE2" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "EBSIO1USE2.JRTCKXETXF": { + "priceDimensions": { + "EBSIO1USE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.125 per GB-month of Provisioned IOPS (io1) volume - US East (Ohio)", + "appliesTo": [], + "rateCode": "EBSIO1USE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.125"} + } + }, + "sku": "EBSIO1USE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json b/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json new file mode 100644 index 0000000..dd9816b --- /dev/null +++ b/internal/pricing/testdata/rds_postgres_db_t3_medium_us_east_2.json @@ -0,0 +1,39 @@ +{ + "product": { + "productFamily": "Database Instance", + "attributes": { + "instanceType": "db.t3.medium", + "regionCode": "us-east-2", + "databaseEngine": "PostgreSQL", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "vcpu": "2", + "memory": "4 GiB" + }, + "sku": "RDSPGT3MEDXYZUS2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "RDSPGT3MEDXYZUS2.JRTCKXETXF": { + "priceDimensions": { + "RDSPGT3MEDXYZUS2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.082 per RDS db.t3.medium Single-AZ instance hour (or partial hour) running PostgreSQL", + "appliesTo": [], + "rateCode": "RDSPGT3MEDXYZUS2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.082"} + } + }, + "sku": "RDSPGT3MEDXYZUS2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_storage_gp2_us_east_2.json b/internal/pricing/testdata/rds_storage_gp2_us_east_2.json new file mode 100644 index 0000000..7ae67b8 --- /dev/null +++ b/internal/pricing/testdata/rds_storage_gp2_us_east_2.json @@ -0,0 +1,36 @@ +{ + "product": { + "productFamily": "Database Storage", + "attributes": { + "volumeType": "General Purpose", + "regionCode": "us-east-2", + "deploymentOption": "Single-AZ", + "storageMedia": "SSD-backed" + }, + "sku": "RDSSTORGP2USE2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "RDSSTORGP2USE2.JRTCKXETXF": { + "priceDimensions": { + "RDSSTORGP2USE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Mo", + "endRange": "Inf", + "description": "$0.115 per GB-month of provisioned gp2 storage running Single-AZ - US East (Ohio)", + "appliesTo": [], + "rateCode": "RDSSTORGP2USE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.115"} + } + }, + "sku": "RDSSTORGP2USE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} From 48372b0bb2216e00c45548002d195e920003506e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 13:46:05 -0400 Subject: [PATCH 08/21] feat: implement cost estimation for Terraform resource changes with ChangeEstimate struct --- cmd/checkpoint134/main.go | 60 +++ internal/pricing/change.go | 282 +++++++++++ internal/pricing/change_test.go | 460 ++++++++++++++++++ internal/pricing/lambda.go | 115 +++++ internal/pricing/lambda_test.go | 195 ++++++++ internal/pricing/nat.go | 74 +++ internal/pricing/nat_test.go | 103 ++++ internal/pricing/rds_cluster_instance.go | 117 +++++ internal/pricing/rds_cluster_instance_test.go | 200 ++++++++ .../testdata/lambda_arm64_us_east_2.json | 35 ++ .../testdata/nat_gateway_us_east_2.json | 35 ++ .../rds_aurora_db_r5_large_us_east_2.json | 39 ++ internal/pricing/types.go | 41 ++ 13 files changed, 1756 insertions(+) create mode 100644 cmd/checkpoint134/main.go create mode 100644 internal/pricing/change.go create mode 100644 internal/pricing/change_test.go create mode 100644 internal/pricing/lambda.go create mode 100644 internal/pricing/lambda_test.go create mode 100644 internal/pricing/nat.go create mode 100644 internal/pricing/nat_test.go create mode 100644 internal/pricing/rds_cluster_instance.go create mode 100644 internal/pricing/rds_cluster_instance_test.go create mode 100644 internal/pricing/testdata/lambda_arm64_us_east_2.json create mode 100644 internal/pricing/testdata/nat_gateway_us_east_2.json create mode 100644 internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json diff --git a/cmd/checkpoint134/main.go b/cmd/checkpoint134/main.go new file mode 100644 index 0000000..82374b9 --- /dev/null +++ b/cmd/checkpoint134/main.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "fmt" + "log" + + "CloudOracle/internal/iac/aws" + "CloudOracle/internal/pricing" +) + +func main() { + ctx := context.Background() + + client, err := pricing.NewClient(ctx) + if err != nil { + log.Fatalf("NewClient: %v", err) + } + + // ---- RDS: postgres db.t3.medium 100GB gp2 single-AZ ---- + rdsAttrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + MultiAZ: false, + } + estRDS, err := pricing.EstimateRDS(ctx, client, rdsAttrs, "us-east-2") + if err != nil { + log.Fatalf("EstimateRDS: %v", err) + } + printEstimate("RDS postgres db.t3.medium 100GB gp2 us-east-2", estRDS) + + fmt.Println() + + // ---- EBS standalone: gp3 200GB ---- + ebsAttrs := &aws.EBSAttributes{ + Type: "gp3", + Size: 200, + } + estEBS, err := pricing.EstimateEBS(ctx, client, ebsAttrs, "us-east-2") + if err != nil { + log.Fatalf("EstimateEBS: %v", err) + } + printEstimate("EBS standalone gp3 200GB us-east-2", estEBS) +} + +func printEstimate(label string, est pricing.Estimate) { + fmt.Printf("=== %s ===\n", label) + fmt.Printf("Total monthly: $%.2f %s\n", est.MonthlyUSD, est.Currency) + fmt.Printf("Confidence: %s\n", est.Confidence) + fmt.Println("Breakdown:") + for _, item := range est.Breakdown { + fmt.Printf(" %-12s $%.4f\n", item.Component, item.MonthlyUSD) + } + fmt.Println("Notes:") + for _, note := range est.Notes { + fmt.Printf(" - %s\n", note) + } +} diff --git a/internal/pricing/change.go b/internal/pricing/change.go new file mode 100644 index 0000000..eb6d93c --- /dev/null +++ b/internal/pricing/change.go @@ -0,0 +1,282 @@ +package pricing + +import ( + "context" + "fmt" + + "CloudOracle/internal/iac" + "CloudOracle/internal/iac/aws" +) + +// EstimateChange returns the cost impact of a single resource change in +// a Terraform plan. It dispatches by rc.Type to the right Estimate* +// function, computes a before/after delta based on rc.Action(), and +// returns a uniform ChangeEstimate that downstream diff/comment code +// can render without caring about resource type. +// +// Action handling (see ChangeEstimate godoc for the sign convention): +// +// - create: BeforeMonthly = 0, AfterMonthly = price(After), delta = AfterMonthly +// - delete: BeforeMonthly = price(Before), AfterMonthly = 0, delta = -BeforeMonthly +// - update / replace: both states priced; delta = After - Before +// - no-op / read: Skipped, all costs 0 +// +// Behaviour for resource types we cannot price (aws_iam_role, S3, +// EKS, ...): EstimateChange returns Skipped=true with a SkipReason +// rather than an error. Callers iterate over the whole plan and want a +// per-resource result for every change, including the unpriced ones — +// silently dropping them would push "why didn't this show up?" lookups +// onto callers. No API call is made for unsupported types. +// +// Data sources (rc.IsManaged()==false) are also Skipped — they have no +// cost impact by definition. +// +// region is the AWS region for pricing queries (e.g. "us-east-2"); all +// resources in a plan should share a region. Multi-region plans need +// separate calls per region. +// +// Errors are returned only for genuine failures: API errors, malformed +// attributes that fail the per-type extractors, parser unit mismatches. +// "Unsupported type" and "no-op action" are NOT errors — they are +// Skipped results. +func EstimateChange(ctx context.Context, src productGetter, rc iac.ResourceChange, region string) (ChangeEstimate, error) { + if region == "" { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: empty region") + } + + action := rc.Action() + out := ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: action, + Currency: "USD", + Confidence: ConfidenceHigh, + } + + if action == iac.ActionNoop || action == iac.ActionRead { + out.Skipped = true + out.SkipReason = "action has no cost impact" + return out, nil + } + if !rc.IsManaged() { + out.Skipped = true + out.SkipReason = "data sources have no cost" + return out, nil + } + + switch action { + case iac.ActionCreate: + afterEst, afterSkip, err := estimateState(ctx, src, rc.Type, rc.Change.After, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s after: %w", rc.Address, err) + } + if afterSkip != "" { + out.Skipped = true + out.SkipReason = afterSkip + return out, nil + } + out.AfterMonthly = afterEst.MonthlyUSD + out.MonthlyDelta = afterEst.MonthlyUSD + out.Confidence = afterEst.Confidence + out.Notes = afterEst.Notes + out.Breakdown = cloneBreakdown(afterEst.Breakdown) + + case iac.ActionDelete: + beforeEst, beforeSkip, err := estimateState(ctx, src, rc.Type, rc.Change.Before, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s before: %w", rc.Address, err) + } + if beforeSkip != "" { + out.Skipped = true + out.SkipReason = beforeSkip + return out, nil + } + out.BeforeMonthly = beforeEst.MonthlyUSD + out.MonthlyDelta = -beforeEst.MonthlyUSD + out.Confidence = beforeEst.Confidence + out.Notes = beforeEst.Notes + out.Breakdown = negateBreakdown(beforeEst.Breakdown) + + case iac.ActionUpdate, iac.ActionReplace: + beforeEst, beforeSkip, err := estimateState(ctx, src, rc.Type, rc.Change.Before, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s before: %w", rc.Address, err) + } + afterEst, afterSkip, err := estimateState(ctx, src, rc.Type, rc.Change.After, region) + if err != nil { + return ChangeEstimate{}, fmt.Errorf("EstimateChange: %s after: %w", rc.Address, err) + } + // If the type is unsupported on either side (it'll be the same + // type on both sides — Terraform doesn't change resource type + // across a single change), skip the whole change. + if beforeSkip != "" || afterSkip != "" { + out.Skipped = true + if beforeSkip != "" { + out.SkipReason = beforeSkip + } else { + out.SkipReason = afterSkip + } + return out, nil + } + out.BeforeMonthly = beforeEst.MonthlyUSD + out.AfterMonthly = afterEst.MonthlyUSD + out.MonthlyDelta = afterEst.MonthlyUSD - beforeEst.MonthlyUSD + out.Confidence = weakestConfidence(beforeEst.Confidence, afterEst.Confidence) + out.Notes = mergeNotes(beforeEst.Notes, afterEst.Notes) + out.Breakdown = mergeDeltaBreakdown(beforeEst.Breakdown, afterEst.Breakdown) + + default: + // Unknown action (a hypothetical future value). Treat as Skipped + // rather than crash — fail safely on unrecognised inputs. + out.Skipped = true + out.SkipReason = fmt.Sprintf("unsupported action %q", string(action)) + return out, nil + } + + return out, nil +} + +// estimateState extracts the typed attributes for resourceType and runs +// the appropriate per-resource estimator. The skipReason return is +// non-empty when the type is unsupported (Extract returned (nil, nil)) +// or when the attribute map was empty — both produce a Skipped change +// rather than an error. Real errors propagate to the caller. +func estimateState(ctx context.Context, src productGetter, resourceType string, attrs map[string]interface{}, region string) (Estimate, string, error) { + if len(attrs) == 0 { + return Estimate{}, "no attributes for state", nil + } + ra, err := aws.Extract(resourceType, attrs) + if err != nil { + return Estimate{}, "", fmt.Errorf("extracting %s: %w", resourceType, err) + } + if ra == nil { + return Estimate{}, "unsupported resource type: " + resourceType, nil + } + switch { + case ra.EC2 != nil: + est, err := EstimateEC2(ctx, src, ra.EC2, region) + return est, "", err + case ra.RDS != nil: + est, err := EstimateRDS(ctx, src, ra.RDS, region) + return est, "", err + case ra.EBS != nil: + est, err := EstimateEBS(ctx, src, ra.EBS, region) + return est, "", err + case ra.Lambda != nil: + est, err := EstimateLambda(ctx, src, ra.Lambda, region) + return est, "", err + case ra.NATGateway != nil: + est, err := EstimateNATGateway(ctx, src, ra.NATGateway, region) + return est, "", err + case ra.RDSClusterInstance != nil: + est, err := EstimateRDSClusterInstance(ctx, src, ra.RDSClusterInstance, region) + return est, "", err + } + // aws.Extract returned a non-nil ResourceAttributes with no inner + // pointer set. Defensively treat as unsupported rather than panic — + // this would only happen if SupportedTypes() and Extract drift apart. + return Estimate{}, "unsupported resource type: " + resourceType, nil +} + +// weakestConfidence returns whichever confidence is "weaker" — high is +// the strongest, low is the weakest. Used by EstimateChange to merge +// the before/after confidences of an update or replace into a single +// value: a high-confidence before paired with a low-confidence after +// produces a low-confidence change. +func weakestConfidence(a, b Confidence) Confidence { + if confidenceRank(a) >= confidenceRank(b) { + return a + } + return b +} + +func confidenceRank(c Confidence) int { + switch c { + case ConfidenceHigh: + return 0 + case ConfidenceMedium: + return 1 + case ConfidenceLow: + return 2 + } + // Unknown values rank as the weakest so they propagate correctly + // through merging instead of being silently dropped. + return 3 +} + +// mergeNotes concatenates two note slices in (before, after) order, +// dropping duplicates so identical caveats from both states don't +// double up in the rendered output. +func mergeNotes(before, after []string) []string { + seen := make(map[string]struct{}, len(before)+len(after)) + out := make([]string, 0, len(before)+len(after)) + for _, n := range before { + if _, ok := seen[n]; !ok { + seen[n] = struct{}{} + out = append(out, n) + } + } + for _, n := range after { + if _, ok := seen[n]; !ok { + seen[n] = struct{}{} + out = append(out, n) + } + } + return out +} + +// cloneBreakdown returns a fresh copy of the breakdown so the caller +// can't accidentally mutate the inner Estimate's slice through the +// returned ChangeEstimate. +func cloneBreakdown(in []LineItem) []LineItem { + if len(in) == 0 { + return nil + } + out := make([]LineItem, len(in)) + copy(out, in) + return out +} + +// negateBreakdown returns a copy of the breakdown with every component's +// MonthlyUSD sign flipped, used for delete actions where the breakdown +// represents a removed cost. +func negateBreakdown(in []LineItem) []LineItem { + if len(in) == 0 { + return nil + } + out := make([]LineItem, len(in)) + for i, li := range in { + out[i] = LineItem{Component: li.Component, MonthlyUSD: -li.MonthlyUSD} + } + return out +} + +// mergeDeltaBreakdown produces a per-component delta from a before and +// after breakdown for update/replace actions. Components in only the +// after appear with their full positive value; components in only the +// before appear with their negated value; components in both are +// after - before. +// +// The output preserves the order of components in `after`, then appends +// components seen only in `before`. Stable order matters because +// downstream comment renderers iterate the breakdown directly. +func mergeDeltaBreakdown(before, after []LineItem) []LineItem { + if len(before) == 0 && len(after) == 0 { + return nil + } + idx := make(map[string]int, len(after)) + out := make([]LineItem, 0, len(after)+len(before)) + for _, li := range after { + idx[li.Component] = len(out) + out = append(out, LineItem{Component: li.Component, MonthlyUSD: li.MonthlyUSD}) + } + for _, li := range before { + if i, ok := idx[li.Component]; ok { + out[i].MonthlyUSD -= li.MonthlyUSD + } else { + idx[li.Component] = len(out) + out = append(out, LineItem{Component: li.Component, MonthlyUSD: -li.MonthlyUSD}) + } + } + return out +} diff --git a/internal/pricing/change_test.go b/internal/pricing/change_test.go new file mode 100644 index 0000000..88185cc --- /dev/null +++ b/internal/pricing/change_test.go @@ -0,0 +1,460 @@ +package pricing + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac" +) + +// ec2CreateAttrs builds a valid aws_instance after-state map matching +// what a Terraform plan would emit (ints come through as float64). +func ec2CreateAttrs(instanceType, volumeType string, volumeSize int) map[string]interface{} { + m := map[string]interface{}{ + "instance_type": instanceType, + "tenancy": "default", + } + if volumeSize > 0 { + m["root_block_device"] = []interface{}{ + map[string]interface{}{ + "volume_size": float64(volumeSize), + "volume_type": volumeType, + }, + } + } + return m +} + +func rdsAttrs(instanceClass string) map[string]interface{} { + return map[string]interface{}{ + "engine": "postgres", + "instance_class": instanceClass, + "allocated_storage": float64(100), + "storage_type": "gp2", + } +} + +func ebsAttrs(volType string, size int) map[string]interface{} { + return map[string]interface{}{ + "type": volType, + "size": float64(size), + } +} + +func TestEstimateChange_CreateEC2(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: ec2CreateAttrs("t3.large", "gp3", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Skipped { + t.Fatalf("Skipped = true, reason=%q", ce.SkipReason) + } + if ce.Action != iac.ActionCreate { + t.Errorf("Action = %q, want create", ce.Action) + } + if ce.BeforeMonthly != 0 { + t.Errorf("BeforeMonthly = %v, want 0", ce.BeforeMonthly) + } + wantAfter := 0.0832*HoursPerMonth + 0.08*50 + if math.Abs(ce.AfterMonthly-wantAfter) > 1e-6 { + t.Errorf("AfterMonthly = %v, want %v", ce.AfterMonthly, wantAfter) + } + if math.Abs(ce.MonthlyDelta-wantAfter) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantAfter) + } + if ce.Currency != "USD" { + t.Errorf("Currency = %q", ce.Currency) + } + if ce.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", ce.Confidence) + } + if len(ce.Breakdown) != 2 { + t.Errorf("Breakdown len = %d, want 2", len(ce.Breakdown)) + } +} + +func TestEstimateChange_DeleteEBS(t *testing.T) { + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_ebs_volume.disk", + Mode: "managed", + Type: "aws_ebs_volume", + Change: iac.Change{ + Actions: []string{"delete"}, + Before: ebsAttrs("gp3", 100), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + wantBefore := 0.08 * 100 + if math.Abs(ce.BeforeMonthly-wantBefore) > 1e-6 { + t.Errorf("BeforeMonthly = %v, want %v", ce.BeforeMonthly, wantBefore) + } + if ce.AfterMonthly != 0 { + t.Errorf("AfterMonthly = %v, want 0", ce.AfterMonthly) + } + if math.Abs(ce.MonthlyDelta+wantBefore) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, -wantBefore) + } + if len(ce.Breakdown) != 1 || ce.Breakdown[0].MonthlyUSD >= 0 { + t.Errorf("Breakdown = %+v, expected single negative line item", ce.Breakdown) + } +} + +func TestEstimateChange_UpdateRDS(t *testing.T) { + compute := loadFixture(t, "rds_postgres_db_t3_medium_us_east_2.json") + storage := loadFixture(t, "rds_storage_gp2_us_east_2.json") + // "before": $0.082/hr compute. "after": $0.164/hr (mutated copy). + afterCompute := strings.Replace(compute, `"USD": "0.082"`, `"USD": "0.164"`, 1) + src := &scriptedGetter{responses: [][]string{{compute}, {storage}, {afterCompute}, {storage}}} + + rc := iac.ResourceChange{ + Address: "aws_db_instance.db", + Mode: "managed", + Type: "aws_db_instance", + Change: iac.Change{ + Actions: []string{"update"}, + Before: rdsAttrs("db.t3.medium"), + After: rdsAttrs("db.t3.large"), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + wantBefore := 0.082*HoursPerMonth + 0.115*100 + wantAfter := 0.164*HoursPerMonth + 0.115*100 + wantDelta := wantAfter - wantBefore + if math.Abs(ce.BeforeMonthly-wantBefore) > 1e-6 { + t.Errorf("BeforeMonthly = %v, want %v", ce.BeforeMonthly, wantBefore) + } + if math.Abs(ce.AfterMonthly-wantAfter) > 1e-6 { + t.Errorf("AfterMonthly = %v, want %v", ce.AfterMonthly, wantAfter) + } + if math.Abs(ce.MonthlyDelta-wantDelta) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantDelta) + } + + // Delta breakdown: Compute should be positive delta, Storage = 0. + gotCompute, gotStorage := math.NaN(), math.NaN() + for _, li := range ce.Breakdown { + switch li.Component { + case "Compute": + gotCompute = li.MonthlyUSD + case "Storage": + gotStorage = li.MonthlyUSD + } + } + wantComputeDelta := (0.164 - 0.082) * HoursPerMonth + if math.Abs(gotCompute-wantComputeDelta) > 1e-6 { + t.Errorf("Compute delta = %v, want %v", gotCompute, wantComputeDelta) + } + if math.Abs(gotStorage) > 1e-6 { + t.Errorf("Storage delta = %v, want 0", gotStorage) + } +} + +func TestEstimateChange_ReplaceEC2(t *testing.T) { + compute := loadFixture(t, "ec2_t3_large_us_east_2.json") + gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") + afterCompute := strings.Replace(compute, `"USD": "0.0832"`, `"USD": "0.1664"`, 1) + src := &scriptedGetter{responses: [][]string{{compute}, {gp3}, {afterCompute}, {gp3}}} + + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"delete", "create"}, // replacement + Before: ec2CreateAttrs("t3.large", "gp3", 50), + After: ec2CreateAttrs("t3.xlarge", "gp3", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("EstimateChange: %v", err) + } + if ce.Action != iac.ActionReplace { + t.Errorf("Action = %q, want replace", ce.Action) + } + wantDelta := (0.1664 - 0.0832) * HoursPerMonth + if math.Abs(ce.MonthlyDelta-wantDelta) > 1e-6 { + t.Errorf("MonthlyDelta = %v, want %v", ce.MonthlyDelta, wantDelta) + } +} + +func TestEstimateChange_NoOp(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"no-op"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } + if ce.MonthlyDelta != 0 || ce.BeforeMonthly != 0 || ce.AfterMonthly != 0 { + t.Errorf("expected all zero costs: %+v", ce) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for no-op, got %d", len(src.calls)) + } +} + +func TestEstimateChange_Read(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"read"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_DataSource(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "data.aws_ami.ubuntu", + Mode: "data", + Type: "aws_ami", + Change: iac.Change{Actions: []string{"create"}}, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true (data source)") + } + if !strings.Contains(ce.SkipReason, "data source") { + t.Errorf("SkipReason = %q", ce.SkipReason) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for data source, got %d", len(src.calls)) + } +} + +func TestEstimateChange_UnsupportedTypeWithCreate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"create"}, + After: map[string]interface{}{"name": "r"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } + if !strings.Contains(ce.SkipReason, "unsupported resource type") { + t.Errorf("SkipReason = %q", ce.SkipReason) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls for unsupported type, got %d", len(src.calls)) + } +} + +func TestEstimateChange_UnsupportedTypeWithDelete(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"delete"}, + Before: map[string]interface{}{"name": "r"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_UnsupportedTypeWithUpdate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_iam_role.r", + Mode: "managed", + Type: "aws_iam_role", + Change: iac.Change{ + Actions: []string{"update"}, + Before: map[string]interface{}{"name": "r"}, + After: map[string]interface{}{"name": "r2"}, + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if !ce.Skipped { + t.Errorf("Skipped = false, want true") + } +} + +func TestEstimateChange_ConfidenceMerging(t *testing.T) { + // Update of an EBS volume gp2 (Medium confidence) → io1 (Low). + // scriptedGetter returns the same minimal product for both calls; only + // the type differs in the Estimate's classification. + src := &scriptedGetter{responses: [][]string{ + {minimalGBMoProduct}, + {minimalGBMoProduct}, + }} + rc := iac.ResourceChange{ + Address: "aws_ebs_volume.disk", + Mode: "managed", + Type: "aws_ebs_volume", + Change: iac.Change{ + Actions: []string{"update"}, + Before: ebsAttrs("gp2", 50), + After: ebsAttrs("io1", 50), + }, + } + ce, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err != nil { + t.Fatalf("err: %v", err) + } + if ce.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low (Medium + Low → Low)", ce.Confidence) + } +} + +func TestEstimateChange_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{Actions: []string{"create"}, After: ec2CreateAttrs("t3.large", "", 0)}, + } + _, err := EstimateChange(context.Background(), src, rc, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateChange_APIErrorPropagated(t *testing.T) { + innerErr := errors.New("AccessDenied") + src := &scriptedGetter{errs: []error{innerErr}} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"create"}, + After: ec2CreateAttrs("t3.large", "", 0), + }, + } + _, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err == nil { + t.Fatal("expected error") + } + if !errors.Is(err, innerErr) { + t.Errorf("error does not wrap inner: %v", err) + } + if !strings.Contains(err.Error(), "aws_instance.web") { + t.Errorf("error missing resource address context: %v", err) + } +} + +func TestEstimateChange_BeforeExtractFailsOnUpdate(t *testing.T) { + src := &scriptedGetter{} + rc := iac.ResourceChange{ + Address: "aws_instance.web", + Mode: "managed", + Type: "aws_instance", + Change: iac.Change{ + Actions: []string{"update"}, + Before: map[string]interface{}{ + "instance_type": 42, // wrong type — extractor fails + }, + After: ec2CreateAttrs("t3.large", "", 0), + }, + } + _, err := EstimateChange(context.Background(), src, rc, "us-east-2") + if err == nil { + t.Fatal("expected error from before extraction failure") + } + if !strings.Contains(err.Error(), "before") { + t.Errorf("error missing 'before' context: %v", err) + } +} + +func TestWeakestConfidence(t *testing.T) { + cases := []struct { + a, b, want Confidence + }{ + {ConfidenceHigh, ConfidenceHigh, ConfidenceHigh}, + {ConfidenceHigh, ConfidenceMedium, ConfidenceMedium}, + {ConfidenceMedium, ConfidenceHigh, ConfidenceMedium}, + {ConfidenceMedium, ConfidenceLow, ConfidenceLow}, + {ConfidenceLow, ConfidenceMedium, ConfidenceLow}, + {ConfidenceLow, ConfidenceLow, ConfidenceLow}, + } + for _, c := range cases { + if got := weakestConfidence(c.a, c.b); got != c.want { + t.Errorf("weakestConfidence(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} + +func TestMergeDeltaBreakdown_BeforeOnlyComponent(t *testing.T) { + before := []LineItem{ + {Component: "Compute", MonthlyUSD: 100}, + {Component: "RootEBS", MonthlyUSD: 5}, + } + after := []LineItem{ + {Component: "Compute", MonthlyUSD: 80}, + } + out := mergeDeltaBreakdown(before, after) + if len(out) != 2 { + t.Fatalf("len = %d, want 2", len(out)) + } + if out[0].Component != "Compute" || out[0].MonthlyUSD != -20 { + t.Errorf("Compute = %+v, want -20", out[0]) + } + if out[1].Component != "RootEBS" || out[1].MonthlyUSD != -5 { + t.Errorf("RootEBS = %+v, want -5", out[1]) + } +} diff --git a/internal/pricing/lambda.go b/internal/pricing/lambda.go new file mode 100644 index 0000000..e4787e4 --- /dev/null +++ b/internal/pricing/lambda.go @@ -0,0 +1,115 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// EstimateLambda calculates the monthly STANDING cost of a Lambda function. +// +// Lambda has two billing components and only the first is estimable from a +// Terraform plan: +// +// 1. Standing cost. $0 unless ProvisionedConcurrency > 0, in which case +// it is ProvisionedConcurrency * (MemorySize/1024) * 730 hours * +// pricePerGBHour. Provisioned Concurrency keeps execution environments +// warm and is billed by the hour regardless of invocations. +// 2. Invocation cost. Per-request fee plus per-GB-second of execution +// time. NOT estimable from a plan — depends on runtime traffic. +// +// This function returns the standing cost only. When ProvisionedConcurrency +// is 0 (the Lambda default) MonthlyUSD is 0 and the Notes explicitly call +// out that invocation charges are not modelled. No API call is made in +// that case — we already know the answer is 0. +// +// Confidence is always Low because the invocation component is unknown, +// even when the standing cost is precisely $0: the user reading the +// estimate should always be aware that real Lambda spend depends on +// traffic. The Notes carry the same warning in human-readable form. +// +// Returns an error for nil attrs, empty region, unknown architectures, +// API failures, missing products, or any unit other than "GB-Hour". +func EstimateLambda(ctx context.Context, src productGetter, attrs *aws.LambdaAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateLambda: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateLambda: nil attrs") + } + + if attrs.ProvisionedConcurrency == 0 { + return Estimate{ + MonthlyUSD: 0, + Currency: "USD", + Breakdown: nil, + Confidence: ConfidenceLow, + Notes: []string{ + "Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled", + }, + }, nil + } + + arch, err := mapLambdaArchitecture(attrs.Architecture) + if err != nil { + return Estimate{}, err + } + + filters := map[string]string{ + "productFamily": "Provisioned Concurrency", + "regionCode": region, + "architecture": arch, + } + products, err := src.GetProducts(ctx, "AWSLambda", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateLambda: provisioned concurrency lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateLambda: no provisioned concurrency price found for %s in %s", attrs.Architecture, region) + } + if len(products) > 1 { + slog.Warn("pricing: Lambda PC query returned multiple products; using first", + "architecture", attrs.Architecture, + "region", region, + "count", len(products), + ) + } + gbHour, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateLambda: parsing PC price: %w", err) + } + if unit != "GB-Hour" { + return Estimate{}, fmt.Errorf("EstimateLambda: expected PC unit GB-Hour, got %q", unit) + } + + memGB := float64(attrs.MemorySize) / 1024.0 + cost := float64(attrs.ProvisionedConcurrency) * memGB * HoursPerMonth * gbHour + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "ProvisionedConcurrency", MonthlyUSD: cost}}, + Confidence: ConfidenceLow, + Notes: []string{ + "Provisioned Concurrency standing cost only; per-invocation charges not modeled", + }, + }, nil +} + +// mapLambdaArchitecture converts a Terraform Lambda architecture value +// to the Pricing API's "architecture" filter value. Terraform uses +// "x86_64"/"arm64"; the Pricing API uses "x86"/"ARM" (note the case +// difference). Unknown values return an error so a typo doesn't silently +// match the wrong product. +func mapLambdaArchitecture(arch string) (string, error) { + switch arch { + case "", "x86_64": + return "x86", nil + case "arm64": + return "ARM", nil + default: + return "", fmt.Errorf("EstimateLambda: unknown architecture %q", arch) + } +} diff --git a/internal/pricing/lambda_test.go b/internal/pricing/lambda_test.go new file mode 100644 index 0000000..5588839 --- /dev/null +++ b/internal/pricing/lambda_test.go @@ -0,0 +1,195 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateLambda_ProvisionedConcurrencyZero_NoAPICall(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 128, + Architecture: "x86_64", + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + if est.MonthlyUSD != 0 { + t.Errorf("MonthlyUSD = %v, want 0", est.MonthlyUSD) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if len(src.calls) != 0 { + t.Errorf("expected no API calls when PC=0, got %d", len(src.calls)) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "Standing cost is $0") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing 'Standing cost is $0' note: %v", est.Notes) + } +} + +func TestEstimateLambda_ProvisionedConcurrency_X86_64(t *testing.T) { + body := strings.Replace( + loadFixture(t, "lambda_arm64_us_east_2.json"), + `"USD": "0.012"`, + `"USD": "0.015"`, + 1, + ) + body = strings.Replace(body, `"architecture": "ARM"`, `"architecture": "x86"`, 1) + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 1024, + Architecture: "x86_64", + ProvisionedConcurrency: 5, + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + want := 5 * 1.0 * HoursPerMonth * 0.015 // 54.75 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + if got := src.calls[0].filters["architecture"]; got != "x86" { + t.Errorf("architecture filter = %q, want x86", got) + } + if got := src.calls[0].filters["productFamily"]; got != "Provisioned Concurrency" { + t.Errorf("productFamily = %q", got) + } + if got := src.calls[0].service; got != "AWSLambda" { + t.Errorf("service = %q, want AWSLambda", got) + } +} + +func TestEstimateLambda_ProvisionedConcurrency_ARM64(t *testing.T) { + body := loadFixture(t, "lambda_arm64_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 10, + } + est, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + want := 10 * 0.5 * HoursPerMonth * 0.012 // 43.8 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if got := src.calls[0].filters["architecture"]; got != "ARM" { + t.Errorf("architecture filter = %q, want ARM", got) + } +} + +func TestEstimateLambda_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateLambda(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{FunctionName: "f"} + _, err := EstimateLambda(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_UnknownArchitecture(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "weird", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unknown architecture") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "x86_64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no provisioned concurrency price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateLambda_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "lambda_arm64_us_east_2.json"), + `"unit": "GB-Hour"`, + `"unit": "Hrs"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected PC unit GB-Hour") { + t.Fatalf("err = %v", err) + } +} + +func TestMapLambdaArchitecture(t *testing.T) { + cases := []struct { + in, want string + err bool + }{ + {"x86_64", "x86", false}, + {"", "x86", false}, + {"arm64", "ARM", false}, + {"weird", "", true}, + } + for _, c := range cases { + got, err := mapLambdaArchitecture(c.in) + if c.err { + if err == nil { + t.Errorf("input %q: expected error", c.in) + } + continue + } + if err != nil { + t.Errorf("input %q: unexpected err: %v", c.in, err) + } + if got != c.want { + t.Errorf("input %q: got %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/pricing/nat.go b/internal/pricing/nat.go new file mode 100644 index 0000000..4dcd7a0 --- /dev/null +++ b/internal/pricing/nat.go @@ -0,0 +1,74 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// EstimateNATGateway calculates the monthly cost of a NAT Gateway. +// +// NAT Gateway has two billing components: +// +// 1. Hourly gateway charge. Region-dependent (~$0.045/hr in us-east-2, +// ~$32.85/month), billed regardless of traffic. Fully estimable from +// the plan. +// 2. Per-GB data processing. ~$0.045/GB on every byte that flows +// through. NOT estimable from a Terraform plan — depends on traffic. +// +// This function returns the hourly gateway cost only (price * 730). The +// Notes always include a warning about the unmodelled data-processing +// charges, because a NAT in a chatty subnet can cost an order of +// magnitude more than the standing cost. Confidence is Medium: the +// standing component is exact, but the unmodelled component can dominate +// the bill in real workloads. +// +// Returns an error for nil attrs, empty region, API failures, missing +// products, or any unit other than "Hrs". +func EstimateNATGateway(ctx context.Context, src productGetter, attrs *aws.NATGatewayAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateNATGateway: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: nil attrs") + } + + filters := map[string]string{ + "productFamily": "NAT Gateway", + "regionCode": region, + "groupDescription": "Hourly charge for NAT Gateways", + } + products, err := src.GetProducts(ctx, "AmazonEC2", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateNATGateway: no NAT Gateway price found in %s", region) + } + if len(products) > 1 { + slog.Warn("pricing: NAT Gateway query returned multiple products; using first", + "region", region, + "count", len(products), + ) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateNATGateway: parsing price: %w", err) + } + if unit != "Hrs" { + return Estimate{}, fmt.Errorf("EstimateNATGateway: expected unit Hrs, got %q", unit) + } + cost := hourly * HoursPerMonth + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Gateway", MonthlyUSD: cost}}, + Confidence: ConfidenceMedium, + Notes: []string{ + "Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled", + }, + }, nil +} diff --git a/internal/pricing/nat_test.go b/internal/pricing/nat_test.go new file mode 100644 index 0000000..2de3da8 --- /dev/null +++ b/internal/pricing/nat_test.go @@ -0,0 +1,103 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateNATGateway_HappyPath(t *testing.T) { + body := loadFixture(t, "nat_gateway_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.NATGatewayAttributes{ + SubnetID: "subnet-abc", + ConnectivityType: "public", + } + est, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateNATGateway: %v", err) + } + want := 0.045 * HoursPerMonth // 32.85 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceMedium { + t.Errorf("Confidence = %q, want medium", est.Confidence) + } + if est.Currency != "USD" { + t.Errorf("Currency = %q", est.Currency) + } + if len(est.Breakdown) != 1 || est.Breakdown[0].Component != "Gateway" { + t.Errorf("Breakdown = %+v", est.Breakdown) + } + foundNote := false + for _, n := range est.Notes { + if strings.Contains(n, "data processing") { + foundNote = true + break + } + } + if !foundNote { + t.Errorf("Notes missing data-processing caveat: %v", est.Notes) + } + + // Filters + c := src.calls[0] + if c.service != "AmazonEC2" { + t.Errorf("service = %q, want AmazonEC2", c.service) + } + for k, want := range map[string]string{ + "productFamily": "NAT Gateway", + "regionCode": "us-east-2", + "groupDescription": "Hourly charge for NAT Gateways", + } { + if c.filters[k] != want { + t.Errorf("filter %s = %q, want %q", k, c.filters[k], want) + } + } +} + +func TestEstimateNATGateway_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateNATGateway(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no NAT Gateway price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateNATGateway_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "nat_gateway_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.NATGatewayAttributes{SubnetID: "subnet-abc"} + _, err := EstimateNATGateway(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected unit Hrs") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/rds_cluster_instance.go b/internal/pricing/rds_cluster_instance.go new file mode 100644 index 0000000..8fded54 --- /dev/null +++ b/internal/pricing/rds_cluster_instance.go @@ -0,0 +1,117 @@ +package pricing + +import ( + "context" + "fmt" + "log/slog" + + "CloudOracle/internal/iac/aws" +) + +// EstimateRDSClusterInstance calculates the monthly cost of a single +// Aurora cluster instance (writer or reader replica). Unlike +// aws_db_instance, this is for aws_rds_cluster_instance, which is the +// unit of compute in Aurora's storage-decoupled architecture. +// +// Aurora bills storage and I/O at the cluster level (aws_rds_cluster), +// not per-instance, so this function returns ONLY compute. The cluster +// header's storage cost is out of scope here — pricing it would need +// EstimateRDSCluster, which is not implemented. +// +// Supported engines: aurora-postgresql, aurora-mysql, the legacy +// "aurora" (MySQL 5.6). Other engines return an error so callers don't +// silently mis-price an unrelated cluster type. +// +// Assumptions made by this function (each lowers Confidence to Low): +// +// 1. Single-AZ deploymentOption. Aurora's redundancy model is +// "multiple instances across AZs in one cluster", so the per-instance +// pricing row is always Single-AZ — multi-AZ is achieved by adding +// more aws_rds_cluster_instance resources, not by toggling a flag. +// 2. licenseModel = "No license required". Aurora doesn't charge a +// license fee on top of the compute rate. +// +// Returns an error for nil attrs, empty region/Engine/InstanceClass, +// unsupported engines, API failures, missing products, or unit +// mismatches. +func EstimateRDSClusterInstance(ctx context.Context, src productGetter, attrs *aws.RDSClusterInstanceAttributes, region string) (Estimate, error) { + if region == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty region") + } + if attrs == nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: nil attrs") + } + if attrs.Engine == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty Engine") + } + if attrs.InstanceClass == "" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: empty InstanceClass") + } + + dbEngine, err := mapAuroraEngine(attrs.Engine) + if err != nil { + return Estimate{}, err + } + + filters := map[string]string{ + "productFamily": "Database Instance", + "databaseEngine": dbEngine, + "instanceType": attrs.InstanceClass, + "regionCode": region, + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + } + products, err := src.GetProducts(ctx, "AmazonRDS", filters) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: lookup: %w", err) + } + if len(products) == 0 { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: no compute price found for %s/%s in %s", attrs.InstanceClass, dbEngine, region) + } + if len(products) > 1 { + slog.Warn("pricing: Aurora cluster instance query returned multiple products; using first", + "instanceClass", attrs.InstanceClass, + "engine", dbEngine, + "region", region, + "count", len(products), + ) + } + hourly, unit, err := parseOnDemandPriceUSD(products[0]) + if err != nil { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: parsing price: %w", err) + } + if unit != "Hrs" { + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: expected unit Hrs, got %q", unit) + } + cost := hourly * HoursPerMonth + + return Estimate{ + MonthlyUSD: cost, + Currency: "USD", + Breakdown: []LineItem{{Component: "Compute", MonthlyUSD: cost}}, + Confidence: ConfidenceLow, + Notes: []string{ + "Cluster-level storage and I/O charges not included (priced at aws_rds_cluster)", + "Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag", + }, + }, nil +} + +// mapAuroraEngine converts a Terraform Aurora engine value to the +// Pricing API's databaseEngine filter value. Aurora MySQL is exposed +// under both "aurora-mysql" (recent) and "aurora" (legacy MySQL 5.6) by +// the AWS provider; both map to the same Pricing API row. Non-Aurora +// engines return an error pointing the caller to EstimateRDS, which is +// the correct entry point for aws_db_instance. +func mapAuroraEngine(engine string) (string, error) { + switch engine { + case "aurora-postgresql": + return "Aurora PostgreSQL", nil + case "aurora-mysql", "aurora": + return "Aurora MySQL", nil + case "postgres", "mysql", "mariadb": + return "", fmt.Errorf("EstimateRDSClusterInstance: engine %q is non-Aurora; use EstimateRDS", engine) + default: + return "", fmt.Errorf("EstimateRDSClusterInstance: unsupported engine %q", engine) + } +} diff --git a/internal/pricing/rds_cluster_instance_test.go b/internal/pricing/rds_cluster_instance_test.go new file mode 100644 index 0000000..debe51a --- /dev/null +++ b/internal/pricing/rds_cluster_instance_test.go @@ -0,0 +1,200 @@ +package pricing + +import ( + "context" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +func TestEstimateRDSClusterInstance_AuroraPostgres_HappyPath(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + est, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + want := 0.29 * HoursPerMonth // 211.7 + if math.Abs(est.MonthlyUSD-want) > 1e-6 { + t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) + } + if est.Confidence != ConfidenceLow { + t.Errorf("Confidence = %q, want low", est.Confidence) + } + foundClusterNote, foundMAZNote := false, false + for _, n := range est.Notes { + if strings.Contains(n, "Cluster-level storage") { + foundClusterNote = true + } + if strings.Contains(n, "Aurora Multi-AZ is via reader replicas") { + foundMAZNote = true + } + } + if !foundClusterNote { + t.Errorf("Notes missing cluster-storage caveat: %v", est.Notes) + } + if !foundMAZNote { + t.Errorf("Notes missing Aurora-Multi-AZ caveat: %v", est.Notes) + } + + // Filters + c := src.calls[0] + if c.service != "AmazonRDS" { + t.Errorf("service = %q, want AmazonRDS", c.service) + } + for k, want := range map[string]string{ + "productFamily": "Database Instance", + "databaseEngine": "Aurora PostgreSQL", + "instanceType": "db.r5.large", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "regionCode": "us-east-2", + } { + if c.filters[k] != want { + t.Errorf("filter %s = %q, want %q", k, c.filters[k], want) + } + } +} + +func TestEstimateRDSClusterInstance_AuroraMySQL_EngineMapping(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-mysql", + } + if _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "Aurora MySQL" { + t.Errorf("databaseEngine = %q, want Aurora MySQL", got) + } +} + +func TestEstimateRDSClusterInstance_AuroraLegacy_MapsToMySQL(t *testing.T) { + body := loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json") + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora", + } + if _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2"); err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + if got := src.calls[0].filters["databaseEngine"]; got != "Aurora MySQL" { + t.Errorf("databaseEngine = %q, want Aurora MySQL (legacy aurora)", got) + } +} + +func TestEstimateRDSClusterInstance_NonAuroraPointsToEstimateRDS(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.t3.medium", + Engine: "postgres", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "use EstimateRDS") { + t.Fatalf("err = %v, want pointer to EstimateRDS", err) + } +} + +func TestEstimateRDSClusterInstance_UnsupportedEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "weird", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "unsupported engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_NilAttrs(t *testing.T) { + src := &scriptedGetter{} + _, err := EstimateRDSClusterInstance(context.Background(), src, nil, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil attrs") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyRegion(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "") + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyEngine(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty Engine") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_EmptyInstanceClass(t *testing.T) { + src := &scriptedGetter{} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "empty InstanceClass") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_NoProducts(t *testing.T) { + src := &scriptedGetter{responses: [][]string{nil}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "no compute price found") { + t.Fatalf("err = %v", err) + } +} + +func TestEstimateRDSClusterInstance_BadUnit(t *testing.T) { + body := strings.Replace( + loadFixture(t, "rds_aurora_db_r5_large_us_east_2.json"), + `"unit": "Hrs"`, + `"unit": "GB-Mo"`, + 1, + ) + src := &scriptedGetter{responses: [][]string{{body}}} + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "c", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + _, err := EstimateRDSClusterInstance(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "expected unit Hrs") { + t.Fatalf("err = %v", err) + } +} diff --git a/internal/pricing/testdata/lambda_arm64_us_east_2.json b/internal/pricing/testdata/lambda_arm64_us_east_2.json new file mode 100644 index 0000000..4dcb058 --- /dev/null +++ b/internal/pricing/testdata/lambda_arm64_us_east_2.json @@ -0,0 +1,35 @@ +{ + "product": { + "productFamily": "Provisioned Concurrency", + "attributes": { + "regionCode": "us-east-2", + "architecture": "ARM", + "servicename": "AWS Lambda" + }, + "sku": "LAMBDAPCARMUSE2" + }, + "serviceCode": "AWSLambda", + "terms": { + "OnDemand": { + "LAMBDAPCARMUSE2.JRTCKXETXF": { + "priceDimensions": { + "LAMBDAPCARMUSE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "GB-Hour", + "endRange": "Inf", + "description": "$0.012 per GB-Hour for ARM64 Provisioned Concurrency", + "appliesTo": [], + "rateCode": "LAMBDAPCARMUSE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.012"} + } + }, + "sku": "LAMBDAPCARMUSE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/nat_gateway_us_east_2.json b/internal/pricing/testdata/nat_gateway_us_east_2.json new file mode 100644 index 0000000..a259dd5 --- /dev/null +++ b/internal/pricing/testdata/nat_gateway_us_east_2.json @@ -0,0 +1,35 @@ +{ + "product": { + "productFamily": "NAT Gateway", + "attributes": { + "regionCode": "us-east-2", + "groupDescription": "Hourly charge for NAT Gateways", + "usagetype": "USE2-NatGateway-Hours" + }, + "sku": "NATGWUSE2" + }, + "serviceCode": "AmazonEC2", + "terms": { + "OnDemand": { + "NATGWUSE2.JRTCKXETXF": { + "priceDimensions": { + "NATGWUSE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.045 per NAT Gateway Hour - US East (Ohio)", + "appliesTo": [], + "rateCode": "NATGWUSE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.045"} + } + }, + "sku": "NATGWUSE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json b/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json new file mode 100644 index 0000000..1ff028c --- /dev/null +++ b/internal/pricing/testdata/rds_aurora_db_r5_large_us_east_2.json @@ -0,0 +1,39 @@ +{ + "product": { + "productFamily": "Database Instance", + "attributes": { + "instanceType": "db.r5.large", + "regionCode": "us-east-2", + "databaseEngine": "Aurora PostgreSQL", + "deploymentOption": "Single-AZ", + "licenseModel": "No license required", + "vcpu": "2", + "memory": "16 GiB" + }, + "sku": "AURPGR5LARGEUSE2" + }, + "serviceCode": "AmazonRDS", + "terms": { + "OnDemand": { + "AURPGR5LARGEUSE2.JRTCKXETXF": { + "priceDimensions": { + "AURPGR5LARGEUSE2.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Hrs", + "endRange": "Inf", + "description": "$0.29 per Aurora PostgreSQL db.r5.large instance hour - US East (Ohio)", + "appliesTo": [], + "rateCode": "AURPGR5LARGEUSE2.JRTCKXETXF.6YS6EN2CT7", + "beginRange": "0", + "pricePerUnit": {"USD": "0.29"} + } + }, + "sku": "AURPGR5LARGEUSE2", + "effectiveDate": "2024-01-01T00:00:00Z", + "offerTermCode": "JRTCKXETXF", + "termAttributes": {} + } + } + }, + "version": "20240101000000", + "publicationDate": "2024-01-01T00:00:00Z" +} diff --git a/internal/pricing/types.go b/internal/pricing/types.go index ed44c6e..32b2e5c 100644 --- a/internal/pricing/types.go +++ b/internal/pricing/types.go @@ -1,5 +1,7 @@ package pricing +import "CloudOracle/internal/iac" + // Estimate is the result of a cost estimation for a single resource. // // MonthlyUSD is the sum of every Breakdown line item, expressed in USD @@ -42,3 +44,42 @@ const ( ConfidenceMedium Confidence = "medium" ConfidenceLow Confidence = "low" ) + +// ChangeEstimate is the cost impact of a single resource change in a +// Terraform plan, produced by EstimateChange. One ChangeEstimate maps +// one-to-one with one iac.ResourceChange. +// +// Sign convention for the cost fields: +// +// - create: BeforeMonthly = 0, AfterMonthly >= 0, MonthlyDelta = AfterMonthly +// - delete: AfterMonthly = 0, BeforeMonthly >= 0, MonthlyDelta = -BeforeMonthly +// - update: both populated, MonthlyDelta = AfterMonthly - BeforeMonthly +// - replace: same as update; the resource is destroyed and re-created so +// the delta is computed against the priced "after" shape, not zero +// - no-op / read / data sources: all three are 0, Skipped is true +// +// Unsupported resource types (aws_iam_role, aws_route53_zone, anything +// outside aws.SupportedTypes()) also produce a ChangeEstimate with all +// costs at 0 and Skipped=true. This is INTENTIONAL: callers iterate over +// the entire plan and want a per-resource result for every change, even +// ones we can't price — silently dropping unsupported types would force +// callers to maintain their own "why didn't this show up?" lookup. +// +// Confidence reflects how reliable the cost numbers are. For Skipped +// estimates the cost is deterministically 0, so Confidence is High; for +// priced estimates it is the weakest of the before/after confidences +// (low > medium > high in "weakness" order). +type ChangeEstimate struct { + ResourceAddress string + ResourceType string + Action iac.Action + BeforeMonthly float64 + AfterMonthly float64 + MonthlyDelta float64 + Currency string + Confidence Confidence + Notes []string + Breakdown []LineItem + Skipped bool + SkipReason string +} From 2290b50ce4fedc1c19fa535a2422f1b2dd18691f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 15:45:28 -0400 Subject: [PATCH 09/21] feat: add integration tests and new utility tools for pricing workflows - Added detailed integration tests for EC2, RDS, EBS, Lambda, NAT Gateway, and Aurora PostgreSQL cost estimation logic. - Introduced `checkpoint135` for estimating Terraform resource change costs from a Terraform plan file. - Added `probe-pricing` for diagnosing AWS Pricing API warnings and exploring resource attributes. - Integrated Terraform files for configuring AWS provider dependencies and locking versions. --- checkpoint/.terraform.lock.hcl | 25 ++ .../aws/5.100.0/windows_amd64/LICENSE.txt | 375 ++++++++++++++++++ checkpoint/main.tf | 63 +++ checkpoint/real_plan.json | 1 + checkpoint/tf.plan | Bin 0 -> 5206 bytes cmd/checkpoint134/main.go | 60 --- cmd/checkpoint135/main.go | 78 ++++ cmd/probe-pricing/main.go | 80 ++++ internal/pricing/ebs.go | 8 +- internal/pricing/ebs_test.go | 19 +- internal/pricing/ec2.go | 7 +- internal/pricing/ec2_test.go | 22 +- internal/pricing/integration_test.go | 224 +++++++++++ internal/pricing/lambda.go | 83 ++-- internal/pricing/lambda_test.go | 86 ++-- internal/pricing/nat.go | 7 +- internal/pricing/rds.go | 32 +- internal/pricing/rds_cluster_instance.go | 17 +- internal/pricing/regions.go | 48 +++ .../testdata/lambda_arm64_us_east_2.json | 28 +- 20 files changed, 1073 insertions(+), 190 deletions(-) create mode 100644 checkpoint/.terraform.lock.hcl create mode 100644 checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt create mode 100644 checkpoint/main.tf create mode 100644 checkpoint/real_plan.json create mode 100644 checkpoint/tf.plan delete mode 100644 cmd/checkpoint134/main.go create mode 100644 cmd/checkpoint135/main.go create mode 100644 cmd/probe-pricing/main.go create mode 100644 internal/pricing/integration_test.go create mode 100644 internal/pricing/regions.go diff --git a/checkpoint/.terraform.lock.hcl b/checkpoint/.terraform.lock.hcl new file mode 100644 index 0000000..92a2bcc --- /dev/null +++ b/checkpoint/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "5.100.0" + constraints = "~> 5.0" + hashes = [ + "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", + "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", + "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", + "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", + "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", + "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", + "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", + "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", + "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", + "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", + "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", + "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", + "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", + "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", + ] +} diff --git a/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt b/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt new file mode 100644 index 0000000..b9ac071 --- /dev/null +++ b/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt @@ -0,0 +1,375 @@ +Copyright (c) 2017 HashiCorp, Inc. + +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/checkpoint/main.tf b/checkpoint/main.tf new file mode 100644 index 0000000..0fc771d --- /dev/null +++ b/checkpoint/main.tf @@ -0,0 +1,63 @@ +terraform { + required_providers { + aws = { source = "hashicorp/aws", version = "~> 5.0" } + } +} + +provider "aws" { + region = "us-east-2" + skip_credentials_validation = true + skip_requesting_account_id = true + skip_metadata_api_check = true + access_key = "test" + secret_key = "test" +} + +resource "aws_instance" "web" { + ami = "ami-12345" + instance_type = "t3.large" + + root_block_device { + volume_size = 50 + volume_type = "gp3" + } +} +resource "aws_db_instance" "main" { + identifier = "main" + engine = "postgres" + instance_class = "db.t3.medium" + allocated_storage = 100 + username = "admin" + password = "changeme" + skip_final_snapshot = true +} + +resource "aws_ebs_volume" "data" { + availability_zone = "us-east-2a" + size = 200 + type = "gp3" + throughput = 125 +} + +resource "aws_lambda_function" "worker" { + function_name = "worker" + role = "arn:aws:iam::123456789012:role/lambda" + handler = "index.handler" + runtime = "python3.12" + memory_size = 512 + timeout = 30 + filename = "dummy.zip" + architectures = ["arm64"] +} + +resource "aws_nat_gateway" "main" { + allocation_id = "eipalloc-12345" + subnet_id = "subnet-12345" +} + +resource "aws_rds_cluster_instance" "replica" { + identifier = "replica" + cluster_identifier = "main-cluster" + instance_class = "db.r5.large" + engine = "aurora-postgresql" +} diff --git a/checkpoint/real_plan.json b/checkpoint/real_plan.json new file mode 100644 index 0000000..46e3fec --- /dev/null +++ b/checkpoint/real_plan.json @@ -0,0 +1 @@ +{"format_version":"1.2","terraform_version":"1.15.2","planned_values":{"root_module":{"resources":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":2,"values":{"allocated_storage":100,"allow_major_version_upgrade":null,"apply_immediately":false,"auto_minor_version_upgrade":true,"blue_green_update":[],"copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"customer_owned_ip_enabled":null,"dedicated_log_volume":false,"delete_automated_backups":true,"deletion_protection":null,"domain":null,"domain_auth_secret_arn":null,"domain_dns_ips":null,"domain_iam_role_name":null,"domain_ou":null,"enabled_cloudwatch_logs_exports":null,"engine":"postgres","final_snapshot_identifier":null,"iam_database_authentication_enabled":null,"identifier":"main","instance_class":"db.t3.medium","manage_master_user_password":null,"max_allocated_storage":null,"monitoring_interval":0,"password":"changeme","password_wo":null,"password_wo_version":null,"performance_insights_enabled":false,"publicly_accessible":false,"replicate_source_db":null,"restore_to_point_in_time":[],"s3_import":[],"skip_final_snapshot":true,"storage_encrypted":null,"tags":null,"timeouts":null,"upgrade_storage_config":null,"username":"admin"},"sensitive_values":{"blue_green_update":[],"listener_endpoint":[],"master_user_secret":[],"password":true,"password_wo":true,"replicas":[],"restore_to_point_in_time":[],"s3_import":[],"tags_all":{},"vpc_security_group_ids":[]}},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"availability_zone":"us-east-2a","final_snapshot":false,"multi_attach_enabled":null,"outpost_arn":null,"size":200,"tags":null,"throughput":125,"timeouts":null,"type":"gp3"},"sensitive_values":{"tags_all":{}}},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":1,"values":{"ami":"ami-12345","credit_specification":[],"get_password_data":false,"hibernation":null,"instance_type":"t3.large","launch_template":[],"root_block_device":[{"delete_on_termination":true,"tags":null,"volume_size":50,"volume_type":"gp3"}],"source_dest_check":true,"tags":null,"timeouts":null,"user_data_replace_on_change":false,"volume_tags":null},"sensitive_values":{"capacity_reservation_specification":[],"cpu_options":[],"credit_specification":[],"ebs_block_device":[],"enclave_options":[],"ephemeral_block_device":[],"instance_market_options":[],"ipv6_addresses":[],"launch_template":[],"maintenance_options":[],"metadata_options":[],"network_interface":[],"private_dns_name_options":[],"root_block_device":[{"tags_all":{}}],"secondary_private_ips":[],"security_groups":[],"tags_all":{},"vpc_security_group_ids":[]}},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"architectures":["arm64"],"code_signing_config_arn":null,"dead_letter_config":[],"description":null,"environment":[],"file_system_config":[],"filename":"dummy.zip","function_name":"worker","handler":"index.handler","image_config":[],"image_uri":null,"kms_key_arn":null,"layers":null,"memory_size":512,"package_type":"Zip","publish":false,"replace_security_groups_on_destroy":null,"replacement_security_group_ids":null,"reserved_concurrent_executions":-1,"role":"arn:aws:iam::123456789012:role/lambda","runtime":"python3.12","s3_bucket":null,"s3_key":null,"s3_object_version":null,"skip_destroy":false,"snap_start":[],"tags":null,"timeout":30,"timeouts":null,"vpc_config":[]},"sensitive_values":{"architectures":[false],"dead_letter_config":[],"environment":[],"ephemeral_storage":[],"file_system_config":[],"image_config":[],"logging_config":[],"snap_start":[],"tags_all":{},"tracing_config":[],"vpc_config":[]}},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"allocation_id":"eipalloc-12345","connectivity_type":"public","secondary_allocation_ids":null,"subnet_id":"subnet-12345","tags":null,"timeouts":null},"sensitive_values":{"secondary_private_ip_addresses":[],"tags_all":{}}},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"auto_minor_version_upgrade":true,"cluster_identifier":"main-cluster","copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"engine":"aurora-postgresql","force_destroy":false,"identifier":"replica","instance_class":"db.r5.large","monitoring_interval":0,"promotion_tier":0,"tags":null,"timeouts":null},"sensitive_values":{"tags_all":{}}}]}},"resource_changes":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"allocated_storage":100,"allow_major_version_upgrade":null,"apply_immediately":false,"auto_minor_version_upgrade":true,"blue_green_update":[],"copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"customer_owned_ip_enabled":null,"dedicated_log_volume":false,"delete_automated_backups":true,"deletion_protection":null,"domain":null,"domain_auth_secret_arn":null,"domain_dns_ips":null,"domain_iam_role_name":null,"domain_ou":null,"enabled_cloudwatch_logs_exports":null,"engine":"postgres","final_snapshot_identifier":null,"iam_database_authentication_enabled":null,"identifier":"main","instance_class":"db.t3.medium","manage_master_user_password":null,"max_allocated_storage":null,"monitoring_interval":0,"password":"changeme","password_wo":null,"password_wo_version":null,"performance_insights_enabled":false,"publicly_accessible":false,"replicate_source_db":null,"restore_to_point_in_time":[],"s3_import":[],"skip_final_snapshot":true,"storage_encrypted":null,"tags":null,"timeouts":null,"upgrade_storage_config":null,"username":"admin"},"after_unknown":{"address":true,"arn":true,"availability_zone":true,"backup_retention_period":true,"backup_target":true,"backup_window":true,"blue_green_update":[],"ca_cert_identifier":true,"character_set_name":true,"database_insights_mode":true,"db_name":true,"db_subnet_group_name":true,"domain_fqdn":true,"endpoint":true,"engine_lifecycle_support":true,"engine_version":true,"engine_version_actual":true,"hosted_zone_id":true,"id":true,"identifier_prefix":true,"iops":true,"kms_key_id":true,"latest_restorable_time":true,"license_model":true,"listener_endpoint":true,"maintenance_window":true,"master_user_secret":true,"master_user_secret_kms_key_id":true,"monitoring_role_arn":true,"multi_az":true,"nchar_character_set_name":true,"network_type":true,"option_group_name":true,"parameter_group_name":true,"performance_insights_kms_key_id":true,"performance_insights_retention_period":true,"port":true,"replica_mode":true,"replicas":true,"resource_id":true,"restore_to_point_in_time":[],"s3_import":[],"snapshot_identifier":true,"status":true,"storage_throughput":true,"storage_type":true,"tags_all":true,"timezone":true,"vpc_security_group_ids":true},"before_sensitive":false,"after_sensitive":{"blue_green_update":[],"listener_endpoint":[],"master_user_secret":[],"password":true,"password_wo":true,"replicas":[],"restore_to_point_in_time":[],"s3_import":[],"tags_all":{},"vpc_security_group_ids":[]}}},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"availability_zone":"us-east-2a","final_snapshot":false,"multi_attach_enabled":null,"outpost_arn":null,"size":200,"tags":null,"throughput":125,"timeouts":null,"type":"gp3"},"after_unknown":{"arn":true,"create_time":true,"encrypted":true,"id":true,"iops":true,"kms_key_id":true,"snapshot_id":true,"tags_all":true},"before_sensitive":false,"after_sensitive":{"tags_all":{}}}},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"ami":"ami-12345","credit_specification":[],"get_password_data":false,"hibernation":null,"instance_type":"t3.large","launch_template":[],"root_block_device":[{"delete_on_termination":true,"tags":null,"volume_size":50,"volume_type":"gp3"}],"source_dest_check":true,"tags":null,"timeouts":null,"user_data_replace_on_change":false,"volume_tags":null},"after_unknown":{"arn":true,"associate_public_ip_address":true,"availability_zone":true,"capacity_reservation_specification":true,"cpu_core_count":true,"cpu_options":true,"cpu_threads_per_core":true,"credit_specification":[],"disable_api_stop":true,"disable_api_termination":true,"ebs_block_device":true,"ebs_optimized":true,"enable_primary_ipv6":true,"enclave_options":true,"ephemeral_block_device":true,"host_id":true,"host_resource_group_arn":true,"iam_instance_profile":true,"id":true,"instance_initiated_shutdown_behavior":true,"instance_lifecycle":true,"instance_market_options":true,"instance_state":true,"ipv6_address_count":true,"ipv6_addresses":true,"key_name":true,"launch_template":[],"maintenance_options":true,"metadata_options":true,"monitoring":true,"network_interface":true,"outpost_arn":true,"password_data":true,"placement_group":true,"placement_partition_number":true,"primary_network_interface_id":true,"private_dns":true,"private_dns_name_options":true,"private_ip":true,"public_dns":true,"public_ip":true,"root_block_device":[{"device_name":true,"encrypted":true,"iops":true,"kms_key_id":true,"tags_all":true,"throughput":true,"volume_id":true}],"secondary_private_ips":true,"security_groups":true,"spot_instance_request_id":true,"subnet_id":true,"tags_all":true,"tenancy":true,"user_data":true,"user_data_base64":true,"vpc_security_group_ids":true},"before_sensitive":false,"after_sensitive":{"capacity_reservation_specification":[],"cpu_options":[],"credit_specification":[],"ebs_block_device":[],"enclave_options":[],"ephemeral_block_device":[],"instance_market_options":[],"ipv6_addresses":[],"launch_template":[],"maintenance_options":[],"metadata_options":[],"network_interface":[],"private_dns_name_options":[],"root_block_device":[{"tags_all":{}}],"secondary_private_ips":[],"security_groups":[],"tags_all":{},"vpc_security_group_ids":[]}}},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"architectures":["arm64"],"code_signing_config_arn":null,"dead_letter_config":[],"description":null,"environment":[],"file_system_config":[],"filename":"dummy.zip","function_name":"worker","handler":"index.handler","image_config":[],"image_uri":null,"kms_key_arn":null,"layers":null,"memory_size":512,"package_type":"Zip","publish":false,"replace_security_groups_on_destroy":null,"replacement_security_group_ids":null,"reserved_concurrent_executions":-1,"role":"arn:aws:iam::123456789012:role/lambda","runtime":"python3.12","s3_bucket":null,"s3_key":null,"s3_object_version":null,"skip_destroy":false,"snap_start":[],"tags":null,"timeout":30,"timeouts":null,"vpc_config":[]},"after_unknown":{"architectures":[false],"arn":true,"code_sha256":true,"dead_letter_config":[],"environment":[],"ephemeral_storage":true,"file_system_config":[],"id":true,"image_config":[],"invoke_arn":true,"last_modified":true,"logging_config":true,"qualified_arn":true,"qualified_invoke_arn":true,"signing_job_arn":true,"signing_profile_version_arn":true,"snap_start":[],"source_code_hash":true,"source_code_size":true,"tags_all":true,"tracing_config":true,"version":true,"vpc_config":[]},"before_sensitive":false,"after_sensitive":{"architectures":[false],"dead_letter_config":[],"environment":[],"ephemeral_storage":[],"file_system_config":[],"image_config":[],"logging_config":[],"snap_start":[],"tags_all":{},"tracing_config":[],"vpc_config":[]}}},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"allocation_id":"eipalloc-12345","connectivity_type":"public","secondary_allocation_ids":null,"subnet_id":"subnet-12345","tags":null,"timeouts":null},"after_unknown":{"association_id":true,"id":true,"network_interface_id":true,"private_ip":true,"public_ip":true,"secondary_private_ip_address_count":true,"secondary_private_ip_addresses":true,"tags_all":true},"before_sensitive":false,"after_sensitive":{"secondary_private_ip_addresses":[],"tags_all":{}}}},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"auto_minor_version_upgrade":true,"cluster_identifier":"main-cluster","copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"engine":"aurora-postgresql","force_destroy":false,"identifier":"replica","instance_class":"db.r5.large","monitoring_interval":0,"promotion_tier":0,"tags":null,"timeouts":null},"after_unknown":{"apply_immediately":true,"arn":true,"availability_zone":true,"ca_cert_identifier":true,"db_parameter_group_name":true,"db_subnet_group_name":true,"dbi_resource_id":true,"endpoint":true,"engine_version":true,"engine_version_actual":true,"id":true,"identifier_prefix":true,"kms_key_id":true,"monitoring_role_arn":true,"network_type":true,"performance_insights_enabled":true,"performance_insights_kms_key_id":true,"performance_insights_retention_period":true,"port":true,"preferred_backup_window":true,"preferred_maintenance_window":true,"publicly_accessible":true,"storage_encrypted":true,"tags_all":true,"writer":true},"before_sensitive":false,"after_sensitive":{"tags_all":{}}}}],"configuration":{"provider_config":{"aws":{"name":"aws","full_name":"registry.terraform.io/hashicorp/aws","version_constraint":"~\u003e 5.0","expressions":{"access_key":{"constant_value":"test"},"region":{"constant_value":"us-east-2"},"secret_key":{"constant_value":"test"},"skip_credentials_validation":{"constant_value":true},"skip_metadata_api_check":{"constant_value":true},"skip_requesting_account_id":{"constant_value":true}}}},"root_module":{"resources":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_config_key":"aws","expressions":{"allocated_storage":{"constant_value":100},"engine":{"constant_value":"postgres"},"identifier":{"constant_value":"main"},"instance_class":{"constant_value":"db.t3.medium"},"password":{"constant_value":"changeme"},"skip_final_snapshot":{"constant_value":true},"username":{"constant_value":"admin"}},"schema_version":2},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_config_key":"aws","expressions":{"availability_zone":{"constant_value":"us-east-2a"},"size":{"constant_value":200},"throughput":{"constant_value":125},"type":{"constant_value":"gp3"}},"schema_version":0},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_config_key":"aws","expressions":{"ami":{"constant_value":"ami-12345"},"instance_type":{"constant_value":"t3.large"},"root_block_device":[{"volume_size":{"constant_value":50},"volume_type":{"constant_value":"gp3"}}]},"schema_version":1},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_config_key":"aws","expressions":{"architectures":{"constant_value":["arm64"]},"filename":{"constant_value":"dummy.zip"},"function_name":{"constant_value":"worker"},"handler":{"constant_value":"index.handler"},"memory_size":{"constant_value":512},"role":{"constant_value":"arn:aws:iam::123456789012:role/lambda"},"runtime":{"constant_value":"python3.12"},"timeout":{"constant_value":30}},"schema_version":0},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_config_key":"aws","expressions":{"allocation_id":{"constant_value":"eipalloc-12345"},"subnet_id":{"constant_value":"subnet-12345"}},"schema_version":0},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_config_key":"aws","expressions":{"cluster_identifier":{"constant_value":"main-cluster"},"engine":{"constant_value":"aurora-postgresql"},"identifier":{"constant_value":"replica"},"instance_class":{"constant_value":"db.r5.large"}},"schema_version":0}]}},"timestamp":"2026-05-08T17:48:38Z","applyable":true,"complete":true,"errored":false} diff --git a/checkpoint/tf.plan b/checkpoint/tf.plan new file mode 100644 index 0000000000000000000000000000000000000000..6d513cdd08375d2add061cf3cc225e7673dbbd60 GIT binary patch literal 5206 zcmd5=WmJ@F)Ez>)yJG;!p&O(nh7J)4=@??@Msny5DG5OkP(Zr7L+Kika_AJ45ClHF z-?~@tUEjUGzx}N9<5}-IXYKW__w2LZqos<1N&>*RJH*gZW591f17HF?Y+M}8p^xDzi~|luvapgVJK&D1j)ppI(wZ<4W$xc zHeel}eK-tgKflV%@`Lyt71NteD%Rga|N1pMB)w|PV-GK`K;1f}Me8xpo~b)+^0D3E zT;OqPklt&KjLuR$O-{{F=RoE7ee`ZACtbp6t-c=5VJ8-IiFyH*{KmhnT|UndFP*Vl1k+R%_9pfDi@F9s4gzGF@=m&v%;HcuJQAlRj`CPY%{ zrSO^4Et?(@my3TtSKfphblvv&EF-9C({9w}#f5Z%YaDIsP-z|M&dd_L&$ny_?)bq| zC}3eKey_8<&4h_a92F`iA04ltV-$EbMlF}DUY`eK>=4w;cTqihfV8DhjD-+;Qlo}{H_0tVZQ=A24W2cn`zYXRWwQL ziQ`&!p9s=|U1+WKqmuB{Qu&9Z7v~h~_z-fI9bYw1A}z7iR5a8x;?(kfRAFmkayfMC+?cZ%B&qsZCq+5aDCR9N-)L4lI;BBQV5>uadl8ePKd}WzqF! zCat&$GsS@=V5dCH5fk%DP-tX=k~uyd$zNc3lzN1A8@)U17^C2QF^PjsV_B6m-W=sD zXxrYuX&)CwoGk7TUtWRQF2+dNE9_xSrr{#Oe95?CaHh^IX9tgnC0ti=NI#)MIDRlx zv0vtdmv!RIV&l^7RouP2eIh_qq|o&vg({Q~bht6tMI&A&)MigeIPvE!BVwV54R zos;Sl3gzokQHF!xs3<8{iU&%C-RHtYpE?(}TbWj58S&mj&0J+DiEUD4vox!~ur!|Q z@En;SOVU2|A$vV9p(tvaJHK*WGx5yR9N0DvQ}sys{GhLKEgfeW>nIAm)h2rk?5zuZ z6v&>jB`Q-DD`b9+S>R29#ADBL3tno%=aOi(oXn9%r=Qu;KPD(wp~lcPo|Mc)IT{T$ z7_u`?a=xX^sHN83Q!W$@C6suQ2qBOAaAq-Uk2{~R!ji-`&0+_<@nT$>_#wT!-3}9BlqW>0*3v z(gJj~=J;~W@>c^D%bXM+A2ZCkOJfXJWCllnS!P?kUSb?O&na-^il>&RTY)<0r;ldX z?%t36VWsP>h$u-$Inhh1x!m(z)3kjjC4Jf1B>1Q~AfW7Wbt-k$mO32wBtp{rahIyW zi4w(b5^PzN=M3(f>T*&@wq{-cAYQjDf(`;?X zxjZIb$#f2R7O-mcBakwjB%#V^3+s`9#K`SuTS7Nj$d9ht$9>Ip3U)6}W41Qc4}Hsg z`ojIkgWX3PVoJfa<1xiVWN*{FODze<=7^fYr=Yc%X!f-p8iV1gF%O9iG8i{XT3k@a z*UF^mq)`&q#TH}J`XBU6akLGeyMn^nRs&ZT`(+E&K8cw&L}1?psloB^_bRoOD2j&H z-M6*XXbmDYhzm#u!d@LfxhRyeJHRfGtLiE|W(xkA_;c3Fmr9t=*@Q7qml^6sRqUOV zDD#%+<2QGX(7R2?lm_EJg~67ATbT_0bpFjxXj+^RYwE!5A%*qa`hu&G7vTa)J~0I= zT;wgoKn|lMnRjtX2_txlbZDX9)bjmLo;Q6`nfqo{!t2Z;;7B%ZAcEfSj9!!^LTA%{ zOg!THR52K~!3o3I*@KXVKVcR<+YOnYa?O#H$_%DZDD#{t_A0a;JIp9pyBC$Q%b(49 z^rYkCEl251DM4sW4OZC);c6gXBfWMWhA)gGV0qzN*vm6;yAcwZ~&iWf0Xh!K_b792?nh9=pG0UaDS_zv- z;njBxcd^6X6eF<|4Fp2{I|Gv}v+bCO>{}GJh4V}gL#_CndWVb?BW#leat|B3a2ICY z4yy1FF_M*Kz;S1?XcFx~%auFhj7SqJfo*QD_KDQ3P9qG`RF zC{blWti|2#Qb}A^R~{rK*DXbhMfne1>QdgcQ5%A84`PYwRgv+eB`+^36bHp{Aoe*! zno$rUKfkM@@~{$Psk0FW*~1ba$&>92N%8Ay|B(Y^qwn69jp>~QQN7t9K=Id!R zpxwdtw)Rg;)uV^QUZ1lXrxzX`(!|b^;`_*D^U#kt?4hR~D!O*H+G^xKrN)w#W9A9_71ne?6U|)r|oism4gIP)iaBLSwOq9C{6W)$~)=$yTkZ zL}~VRu-vY_O%KYT&_J#@Ndu!m_d47msxF1D))sRojDjnsD z7_zRF5FYGK5bs0Y&8p8vrF~A|r;``y?1Fdd{24|w)M|Wr#8YilY42JAYto;8lWj`u zIiQi-5_j{>K_RyXnX#|`btMc#iK@tAW*ZoSd4d`t*jH_`4ijMeI!G|=NtAXn{7}VE zGK5QUfN@i5|yD@_7JDKO0J(G2&q+{6v1pr_cR5YFsH|G#@0~@ z)HQ`Lsj4$@Zdmbd9uJTo7}ZKkTr5U9tg5U@SXP2ly-UzRM3ZPl znqx!=-&0uyko-3sTCxSd=Yz^f@R0rz?w1Ec7QqB#d8fK2r>o+1K1QV|V!ZsK6mmxh z$J5fOivkDuN-Cl(%!O*s)>vyeWy|xIgg2_OcbNLAqo9wrzKvVFVADw{}wXemcQA zUU>^mSzfGFiKl38?E;%pEDl{@wU~5|;kgPUFbIbLVW!-jL1}-O1ddun@m@`WfPO7G$hCmI!cH9A^4Qz0%U{IN2|4_LVGg&S5H$o!>Rxy25&?02`H@$X9T zv$426tliwqZJgblxE-A>9k}f*9sg=9y=l!@8YO(RFINFEHUZDzj~=PKN$EafWDM%U zQ&GbSNea_TsiF0<-OqY5xR(@g#>)ruqBoM1{wA{nKbcnpP4I`fOg|v&DegoAW74^C+mof zW0FyX(Y$JovsY_l3F2xNQMa#oyy(}FlLx(Fa-KsRNP)eQz?1d@Sv^v!1?LQ zH6YQn<7RW?;z3}*^+|8!<=1)oPQP>e>zk#6x0m4+muo*PZaX!r6TQbnCL$!j$OUSX zQnm+Am66M4p-Y)Iz?Yo&Aou3w?2v`i2piK>8fi3;K45>ha|cprp@76Dl8Oky#JXcs z%1CJASmtDop7Nwrh4uUnHA8}|^b%o&&Mg3&`AKgT0hu`BN8*JF%xx{?;}lDJyxAm zc^|X=T5@^RGosc@SRpXFfWt|?Oc4yqDzwk;9Wk5i9@FVbtrALjQyRB3Rv|N#&M_B;et)b| z8`ySczdzZI90T9Rs-G5=5!su5-t5ZXbvhySil|wOXR@YY0GQ6TIu~QyT05{$BIwtS zg~h#O;^9`8J-24MJHKSw8g$q0|LY!L+Z@AEC;&jvy`O!EgiHeXwWIpoS^V5l{T=_< zSp75k_fh}TeEfpdomug)`MVYQuk4?8;1>w6|H}TmIrwLZ-(~Hmj{HLRA0_^MT>rDu z?^^X!27jR#?") + } + + ctx := context.Background() + + plan, err := iac.ParsePlanFile(os.Args[1]) + if err != nil { + log.Fatalf("parse: %v", err) + } + + client, err := pricing.NewClient(ctx) + if err != nil { + log.Fatalf("NewClient: %v", err) + } + + fmt.Printf("Plan parsed: %d resource changes\n\n", len(plan.ResourceChanges)) + + estimates := make([]pricing.ChangeEstimate, 0, len(plan.ResourceChanges)) + var totalDelta float64 + for _, rc := range plan.ResourceChanges { + est, err := pricing.EstimateChange(ctx, client, rc, "us-east-2") + if err != nil { + fmt.Printf("[FAIL] %s (%s): %v\n", rc.Address, rc.Type, err) + continue + } + estimates = append(estimates, est) + totalDelta += est.MonthlyDelta + } + + sort.Slice(estimates, func(i, j int) bool { + return math.Abs(estimates[i].MonthlyDelta) > math.Abs(estimates[j].MonthlyDelta) + }) + + fmt.Printf("%-50s %-10s %-12s %-10s\n", "RESOURCE", "ACTION", "DELTA/MO", "CONFIDENCE") + fmt.Println(string(make([]byte, 90))) + for _, est := range estimates { + marker := "" + if est.Skipped { + marker = " (skipped: " + est.SkipReason + ")" + } + fmt.Printf("%-50s %-10s $%-11.2f %-10s%s\n", + est.ResourceAddress, est.Action, est.MonthlyDelta, est.Confidence, marker) + } + + fmt.Printf("\n=== Total monthly delta: $%.2f ===\n\n", totalDelta) + + fmt.Println("--- Detailed breakdowns ---") + for _, est := range estimates { + if est.Skipped { + continue + } + fmt.Printf("\n%s (%s, action=%s):\n", est.ResourceAddress, est.ResourceType, est.Action) + fmt.Printf(" Before: $%.4f | After: $%.4f | Delta: $%.4f\n", + est.BeforeMonthly, est.AfterMonthly, est.MonthlyDelta) + for _, item := range est.Breakdown { + fmt.Printf(" %-12s $%.4f\n", item.Component, item.MonthlyUSD) + } + for _, note := range est.Notes { + fmt.Printf(" - %s\n", note) + } + } +} diff --git a/cmd/probe-pricing/main.go b/cmd/probe-pricing/main.go new file mode 100644 index 0000000..0f4fd00 --- /dev/null +++ b/cmd/probe-pricing/main.go @@ -0,0 +1,80 @@ +// Command probe-pricing inspects raw AWS Pricing API responses for a given +// service code and filter set. It exists for two reasons: +// +// 1. Diagnosing "multiple products" warnings: when an EstimateXxx mapper +// warns that a query returned >1 product, run the same filters through +// the probe to see exactly which attributes differ between the products +// and pick a tighter filter to add. +// 2. Ad-hoc exploration of new resource types before writing a mapper — +// dumping a known-good filter set is the fastest way to learn the +// attribute vocabulary AWS uses for that productFamily. +// +// Usage: +// +// go run ./cmd/probe-pricing '' +// +// Example: +// +// go run ./cmd/probe-pricing AmazonRDS \ +// '{"productFamily":"Database Storage","volumeType":"General Purpose","deploymentOption":"Single-AZ","regionCode":"us-east-2"}' +// +// Requires AWS credentials in the standard chain (env vars, shared config, +// instance metadata). The Pricing API endpoint is forced to us-east-1 by +// pricing.NewClient regardless of the resource's region — the resource +// region is a filter value, not the endpoint region. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "sort" + + "CloudOracle/internal/pricing" +) + +func main() { + if len(os.Args) < 3 { + log.Fatal("usage: probe-pricing ") + } + + var filters map[string]string + if err := json.Unmarshal([]byte(os.Args[2]), &filters); err != nil { + log.Fatalf("invalid filters JSON: %v", err) + } + + ctx := context.Background() + client, err := pricing.NewClient(ctx) + if err != nil { + log.Fatalf("NewClient: %v", err) + } + + products, err := client.GetProducts(ctx, os.Args[1], filters) + if err != nil { + log.Fatalf("GetProducts: %v", err) + } + + fmt.Printf("Got %d products for %s with filters %v\n\n", len(products), os.Args[1], filters) + for i, p := range products { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(p), &parsed); err != nil { + fmt.Printf("[%d] parse error: %v\n", i, err) + continue + } + product, _ := parsed["product"].(map[string]interface{}) + attrs, _ := product["attributes"].(map[string]interface{}) + + fmt.Printf("[%d] sku=%v\n", i, product["sku"]) + keys := make([]string, 0, len(attrs)) + for k := range attrs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" %-30s = %v\n", k, attrs[k]) + } + fmt.Println() + } +} diff --git a/internal/pricing/ebs.go b/internal/pricing/ebs.go index c82d42d..e166346 100644 --- a/internal/pricing/ebs.go +++ b/internal/pricing/ebs.go @@ -3,7 +3,6 @@ package pricing import ( "context" "fmt" - "log/slog" "CloudOracle/internal/iac/aws" ) @@ -41,11 +40,8 @@ func lookupEBSStoragePrice(ctx context.Context, src productGetter, volumeType, r return 0, fmt.Errorf("lookupEBSStoragePrice: no EBS price found for %s in %s", volumeType, region) } if len(products) > 1 { - slog.Warn("pricing: EBS storage query returned multiple products; using first", - "volumeType", volumeType, - "region", region, - "count", len(products), - ) + return 0, fmt.Errorf("lookupEBSStoragePrice: query returned %d products; filter under-constrained for volumeType=%s region=%s", + len(products), volumeType, region) } gbMo, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { diff --git a/internal/pricing/ebs_test.go b/internal/pricing/ebs_test.go index e8d6d44..58c73aa 100644 --- a/internal/pricing/ebs_test.go +++ b/internal/pricing/ebs_test.go @@ -3,7 +3,6 @@ package pricing import ( "context" "errors" - "log/slog" "math" "strings" "testing" @@ -74,21 +73,21 @@ func TestLookupEBSStoragePrice_NoProducts(t *testing.T) { } } -func TestLookupEBSStoragePrice_MultipleProductsUsesFirst(t *testing.T) { +func TestLookupEBSStoragePrice_MultipleProductsErrors(t *testing.T) { + // After 13.6 tightening, ambiguity is a hard error rather than a warn. gp3 := loadFixture(t, "ec2_gp3_us_east_2.json") second := strings.Replace(gp3, `"USD": "0.08"`, `"USD": "9.99"`, 1) src := &scriptedGetter{responses: [][]string{{gp3, second}}} - logs := captureLogs(t, slog.LevelWarn) - price, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") - if err != nil { - t.Fatalf("err: %v", err) + _, err := lookupEBSStoragePrice(context.Background(), src, "gp3", "us-east-2") + if err == nil { + t.Fatal("expected error on ambiguous EBS query") } - if math.Abs(price-0.08) > 1e-9 { - t.Errorf("price = %v, want 0.08 (first product)", price) + if !strings.Contains(err.Error(), "filter under-constrained") { + t.Errorf("err = %v, want 'filter under-constrained' message", err) } - if !strings.Contains(logs.String(), "multiple products") { - t.Errorf("expected warn log, got: %s", logs.String()) + if !strings.Contains(err.Error(), "volumeType=gp3") { + t.Errorf("err missing volumeType context: %v", err) } } diff --git a/internal/pricing/ec2.go b/internal/pricing/ec2.go index edbf0f5..4ea25d9 100644 --- a/internal/pricing/ec2.go +++ b/internal/pricing/ec2.go @@ -111,11 +111,8 @@ func lookupComputePrice(ctx context.Context, src productGetter, attrs *aws.EC2At return 0, fmt.Errorf("EstimateEC2: no compute price found for %s in %s", attrs.InstanceType, region) } if len(products) > 1 { - slog.Warn("pricing: EC2 compute query returned multiple products; using first", - "instanceType", attrs.InstanceType, - "region", region, - "count", len(products), - ) + return 0, fmt.Errorf("EstimateEC2: compute query returned %d products; filter under-constrained for instanceType=%s region=%s", + len(products), attrs.InstanceType, region) } hourly, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { diff --git a/internal/pricing/ec2_test.go b/internal/pricing/ec2_test.go index f5ece8b..e75b71d 100644 --- a/internal/pricing/ec2_test.go +++ b/internal/pricing/ec2_test.go @@ -199,24 +199,24 @@ func TestEstimateEC2_NoComputeProducts(t *testing.T) { } } -func TestEstimateEC2_MultipleComputeProductsUsesFirst(t *testing.T) { +func TestEstimateEC2_MultipleComputeProductsErrors(t *testing.T) { + // Multiple products signals an under-constrained filter set, not a + // "pick one and warn" scenario. Tightened in milestone 13.6 — see + // the godoc on lookupComputePrice. first := loadFixture(t, "ec2_t3_large_us_east_2.json") second := strings.Replace(first, `"USD": "0.0832"`, `"USD": "9.99"`, 1) src := &scriptedGetter{responses: [][]string{{first, second}}} - logs := captureLogs(t, slog.LevelWarn) - attrs := &aws.EC2Attributes{InstanceType: "t3.large"} - est, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") - if err != nil { - t.Fatalf("EstimateEC2: %v", err) + _, err := EstimateEC2(context.Background(), src, attrs, "us-east-2") + if err == nil { + t.Fatal("expected error on ambiguous compute query") } - wantCompute := 0.0832 * HoursPerMonth - if math.Abs(est.MonthlyUSD-wantCompute) > 1e-6 { - t.Errorf("MonthlyUSD = %v, want %v (must use first product)", est.MonthlyUSD, wantCompute) + if !strings.Contains(err.Error(), "filter under-constrained") { + t.Errorf("err = %v, want 'filter under-constrained' message", err) } - if !strings.Contains(logs.String(), "multiple products") { - t.Errorf("expected warn log about multiple products, got: %s", logs.String()) + if !strings.Contains(err.Error(), "instanceType=t3.large") { + t.Errorf("err missing instanceType context: %v", err) } } diff --git a/internal/pricing/integration_test.go b/internal/pricing/integration_test.go new file mode 100644 index 0000000..a7f814e --- /dev/null +++ b/internal/pricing/integration_test.go @@ -0,0 +1,224 @@ +//go:build integration + +// Integration tests in this file hit the real AWS Pricing API. Run with: +// +// go test -tags=integration ./internal/pricing/... +// +// They require AWS credentials configured via the standard chain +// (env vars, shared credentials file, EC2/ECS/EKS instance metadata, +// SSO). When credentials are missing, NewClient itself does not error +// — it constructs a client that will fail at the first API call. To +// keep the suite friendly to credential-less machines we still attempt +// a NewClient and skip the test if construction fails; the per-test +// API call surfaces auth issues clearly through their own errors. +// +// What these tests check: +// +// 1. Each EstimateXxx mapper returns a price in a sane range against +// live AWS data (the upper bound has slack so AWS price changes +// don't flake the suite). +// 2. None of the queries return >1 product after milestone 13.6's +// filter tightening — TestIntegration_NoAmbiguity verifies all six +// mappers in one pass and asserts no "filter under-constrained" +// error escapes. + +package pricing + +import ( + "context" + "strings" + "testing" + + "CloudOracle/internal/iac/aws" +) + +const integrationRegion = "us-east-2" + +// integrationClient builds a live pricing.Client or skips the test if +// AWS credential resolution fails. NewClient currently only fails when +// LoadDefaultConfig itself errors — typically because of a malformed +// AWS_PROFILE or a broken shared config — but skipping cleanly there +// keeps the suite usable on any machine. +func integrationClient(t *testing.T) *Client { + t.Helper() + c, err := NewClient(context.Background()) + if err != nil { + t.Skipf("integration test requires AWS credentials; got error: %v", err) + } + return c +} + +func inBand(t *testing.T, label string, got, lo, hi float64) { + t.Helper() + if got < lo || got > hi { + t.Errorf("%s = %.4f, want in band [%.4f, %.4f]", label, got, lo, hi) + } +} + +func TestIntegration_EC2_T3Large_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + } + est, err := EstimateEC2(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateEC2: %v", err) + } + // As of late 2024 the price is ~$64.74. The band is wide enough to + // absorb routine AWS price tweaks without flaking the suite. + inBand(t, "EC2 t3.large+50GB gp3", est.MonthlyUSD, 50, 80) +} + +func TestIntegration_RDS_PostgresT3Medium_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + } + est, err := EstimateRDS(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateRDS: %v", err) + } + // ~$71.36 baseline. + inBand(t, "RDS db.t3.medium+100GB gp2", est.MonthlyUSD, 50, 90) +} + +func TestIntegration_EBS_GP3_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.EBSAttributes{Type: "gp3", Size: 200} + est, err := EstimateEBS(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateEBS: %v", err) + } + // $0.08/GB-month * 200 = $16.00. + inBand(t, "EBS gp3 200GB", est.MonthlyUSD, 14, 20) +} + +func TestIntegration_Lambda_PCZero(t *testing.T) { + // PC=0 must short-circuit and return $0 with no API call. We cannot + // observe "no API call" through a *Client, but we can still assert + // the contract: cost is exactly 0. + c := integrationClient(t) + attrs := &aws.LambdaAttributes{ + FunctionName: "test", + MemorySize: 512, + Architecture: "arm64", + } + est, err := EstimateLambda(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateLambda: %v", err) + } + if est.MonthlyUSD != 0 { + t.Errorf("MonthlyUSD = %v, want exactly 0 for PC=0", est.MonthlyUSD) + } +} + +func TestIntegration_NATGateway_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.NATGatewayAttributes{ + SubnetID: "subnet-irrelevant", + ConnectivityType: "public", + } + est, err := EstimateNATGateway(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateNATGateway: %v", err) + } + // $0.045/hr * 730 = $32.85. + inBand(t, "NAT Gateway", est.MonthlyUSD, 30, 40) +} + +func TestIntegration_RDSClusterInstance_AuroraPGR5Large_USEast2(t *testing.T) { + c := integrationClient(t) + attrs := &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + } + est, err := EstimateRDSClusterInstance(context.Background(), c, attrs, integrationRegion) + if err != nil { + t.Fatalf("EstimateRDSClusterInstance: %v", err) + } + // $0.29/hr * 730 = $211.7. + inBand(t, "Aurora PG db.r5.large", est.MonthlyUSD, 190, 230) +} + +// TestIntegration_NoAmbiguity exercises every mapper end-to-end in one +// pass and asserts that none of them surface the "filter under- +// constrained" error introduced in milestone 13.6. This is the +// regression test that catches a future AWS catalogue change adding a +// new SKU variant we hadn't anticipated. +func TestIntegration_NoAmbiguity(t *testing.T) { + c := integrationClient(t) + ctx := context.Background() + + type call func() error + calls := map[string]call{ + "EC2": func() error { + _, err := EstimateEC2(ctx, c, &aws.EC2Attributes{ + InstanceType: "t3.large", + Tenancy: "default", + RootBlockSize: 50, + RootBlockType: "gp3", + }, integrationRegion) + return err + }, + "RDS": func() error { + _, err := EstimateRDS(ctx, c, &aws.RDSAttributes{ + Engine: "postgres", + InstanceClass: "db.t3.medium", + AllocatedStorage: 100, + StorageType: "gp2", + }, integrationRegion) + return err + }, + "EBS": func() error { + _, err := EstimateEBS(ctx, c, &aws.EBSAttributes{Type: "gp3", Size: 200}, integrationRegion) + return err + }, + "Lambda": func() error { + // Use PC>0 so the API is actually hit (PC=0 short-circuits). + _, err := EstimateLambda(ctx, c, &aws.LambdaAttributes{ + FunctionName: "test", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + }, integrationRegion) + return err + }, + "NATGateway": func() error { + _, err := EstimateNATGateway(ctx, c, &aws.NATGatewayAttributes{ + SubnetID: "subnet-irrelevant", + ConnectivityType: "public", + }, integrationRegion) + return err + }, + "AuroraClusterInstance": func() error { + _, err := EstimateRDSClusterInstance(ctx, c, &aws.RDSClusterInstanceAttributes{ + ClusterIdentifier: "test-cluster", + InstanceClass: "db.r5.large", + Engine: "aurora-postgresql", + }, integrationRegion) + return err + }, + } + + for name, fn := range calls { + t.Run(name, func(t *testing.T) { + err := fn() + if err == nil { + return + } + if strings.Contains(err.Error(), "filter under-constrained") { + t.Fatalf("%s: ambiguity error after 13.6 tightening: %v", name, err) + } + // Any other error is unexpected too — surface it loudly so + // it's debugged rather than silently passed over. + t.Fatalf("%s: %v", name, err) + }) + } +} diff --git a/internal/pricing/lambda.go b/internal/pricing/lambda.go index e4787e4..b3cb22b 100644 --- a/internal/pricing/lambda.go +++ b/internal/pricing/lambda.go @@ -3,35 +3,52 @@ package pricing import ( "context" "fmt" - "log/slog" "CloudOracle/internal/iac/aws" ) -// EstimateLambda calculates the monthly STANDING cost of a Lambda function. +// SecondsPerMonth is HoursPerMonth * 3600. AWS Lambda Provisioned +// Concurrency is priced per GB-second rather than per GB-hour, so this +// is the conversion factor used to compute monthly cost from the +// per-second rate. Defined alongside HoursPerMonth so callers don't +// rediscover the constant on every per-second mapper. +const SecondsPerMonth = HoursPerMonth * 3600 + +// EstimateLambda calculates the monthly STANDING cost of a Lambda +// function. // -// Lambda has two billing components and only the first is estimable from a -// Terraform plan: +// Lambda has two billing components and only the first is estimable +// from a Terraform plan: // -// 1. Standing cost. $0 unless ProvisionedConcurrency > 0, in which case -// it is ProvisionedConcurrency * (MemorySize/1024) * 730 hours * -// pricePerGBHour. Provisioned Concurrency keeps execution environments -// warm and is billed by the hour regardless of invocations. +// 1. Standing cost. $0 unless ProvisionedConcurrency > 0, in which +// case it is ProvisionedConcurrency * (MemorySize/1024) * +// SecondsPerMonth * pricePerGBSecond. Provisioned Concurrency +// keeps execution environments warm and is billed by the second +// regardless of invocations. // 2. Invocation cost. Per-request fee plus per-GB-second of execution // time. NOT estimable from a plan — depends on runtime traffic. // -// This function returns the standing cost only. When ProvisionedConcurrency -// is 0 (the Lambda default) MonthlyUSD is 0 and the Notes explicitly call -// out that invocation charges are not modelled. No API call is made in -// that case — we already know the answer is 0. +// This function returns the standing cost only. When +// ProvisionedConcurrency is 0 (the Lambda default) MonthlyUSD is 0 and +// the Notes explicitly call out that invocation charges are not +// modelled. No API call is made in that case — we already know the +// answer is 0. // // Confidence is always Low because the invocation component is unknown, // even when the standing cost is precisely $0: the user reading the // estimate should always be aware that real Lambda spend depends on // traffic. The Notes carry the same warning in human-readable form. // +// Filter strategy: AWS exposes the standing cost SKU under +// productFamily="Serverless" with usagetype="-Lambda- +// Provisioned-Concurrency" (x86_64) or "...-Provisioned-Concurrency-ARM" +// (arm64). There is no `architecture` attribute on these products, so +// usagetype IS the only architecture discriminator — that's why +// regionPrefix exists. +// // Returns an error for nil attrs, empty region, unknown architectures, -// API failures, missing products, or any unit other than "GB-Hour". +// API failures, missing products, ambiguous matches, or any unit other +// than "Lambda-GB-Second". func EstimateLambda(ctx context.Context, src productGetter, attrs *aws.LambdaAttributes, region string) (Estimate, error) { if region == "" { return Estimate{}, fmt.Errorf("EstimateLambda: empty region") @@ -52,40 +69,38 @@ func EstimateLambda(ctx context.Context, src productGetter, attrs *aws.LambdaAtt }, nil } - arch, err := mapLambdaArchitecture(attrs.Architecture) + suffix, err := lambdaArchitectureSuffix(attrs.Architecture) if err != nil { return Estimate{}, err } + usageType := fmt.Sprintf("%s-Lambda-Provisioned-Concurrency%s", regionPrefix(region), suffix) filters := map[string]string{ - "productFamily": "Provisioned Concurrency", + "productFamily": "Serverless", "regionCode": region, - "architecture": arch, + "usagetype": usageType, } products, err := src.GetProducts(ctx, "AWSLambda", filters) if err != nil { return Estimate{}, fmt.Errorf("EstimateLambda: provisioned concurrency lookup: %w", err) } if len(products) == 0 { - return Estimate{}, fmt.Errorf("EstimateLambda: no provisioned concurrency price found for %s in %s", attrs.Architecture, region) + return Estimate{}, fmt.Errorf("EstimateLambda: no provisioned concurrency price found for usagetype=%s", usageType) } if len(products) > 1 { - slog.Warn("pricing: Lambda PC query returned multiple products; using first", - "architecture", attrs.Architecture, - "region", region, - "count", len(products), - ) + return Estimate{}, fmt.Errorf("EstimateLambda: PC query returned %d products; filter under-constrained for usagetype=%s region=%s", + len(products), usageType, region) } - gbHour, unit, err := parseOnDemandPriceUSD(products[0]) + gbSecond, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { return Estimate{}, fmt.Errorf("EstimateLambda: parsing PC price: %w", err) } - if unit != "GB-Hour" { - return Estimate{}, fmt.Errorf("EstimateLambda: expected PC unit GB-Hour, got %q", unit) + if unit != "Lambda-GB-Second" { + return Estimate{}, fmt.Errorf("EstimateLambda: expected PC unit Lambda-GB-Second, got %q", unit) } memGB := float64(attrs.MemorySize) / 1024.0 - cost := float64(attrs.ProvisionedConcurrency) * memGB * HoursPerMonth * gbHour + cost := float64(attrs.ProvisionedConcurrency) * memGB * SecondsPerMonth * gbSecond return Estimate{ MonthlyUSD: cost, @@ -98,17 +113,17 @@ func EstimateLambda(ctx context.Context, src productGetter, attrs *aws.LambdaAtt }, nil } -// mapLambdaArchitecture converts a Terraform Lambda architecture value -// to the Pricing API's "architecture" filter value. Terraform uses -// "x86_64"/"arm64"; the Pricing API uses "x86"/"ARM" (note the case -// difference). Unknown values return an error so a typo doesn't silently -// match the wrong product. -func mapLambdaArchitecture(arch string) (string, error) { +// lambdaArchitectureSuffix returns the suffix appended to the +// Provisioned Concurrency usagetype for a given Terraform architecture. +// Empty / "x86_64" → no suffix; "arm64" → "-ARM". Unknown values +// return an error so a typo in the plan doesn't silently match (and +// mis-price as) the wrong architecture's SKU. +func lambdaArchitectureSuffix(arch string) (string, error) { switch arch { case "", "x86_64": - return "x86", nil + return "", nil case "arm64": - return "ARM", nil + return "-ARM", nil default: return "", fmt.Errorf("EstimateLambda: unknown architecture %q", arch) } diff --git a/internal/pricing/lambda_test.go b/internal/pricing/lambda_test.go index 5588839..7c658b1 100644 --- a/internal/pricing/lambda_test.go +++ b/internal/pricing/lambda_test.go @@ -42,13 +42,10 @@ func TestEstimateLambda_ProvisionedConcurrencyZero_NoAPICall(t *testing.T) { } func TestEstimateLambda_ProvisionedConcurrency_X86_64(t *testing.T) { - body := strings.Replace( - loadFixture(t, "lambda_arm64_us_east_2.json"), - `"USD": "0.012"`, - `"USD": "0.015"`, - 1, - ) - body = strings.Replace(body, `"architecture": "ARM"`, `"architecture": "x86"`, 1) + // x86_64 SKU: usagetype has no -ARM suffix, price is $0.0000041667/GB-Second. + body := loadFixture(t, "lambda_arm64_us_east_2.json") + body = strings.Replace(body, `"USD": "0.0000033334"`, `"USD": "0.0000041667"`, 1) + body = strings.Replace(body, `USE2-Lambda-Provisioned-Concurrency-ARM`, `USE2-Lambda-Provisioned-Concurrency`, -1) src := &scriptedGetter{responses: [][]string{{body}}} attrs := &aws.LambdaAttributes{ @@ -61,18 +58,18 @@ func TestEstimateLambda_ProvisionedConcurrency_X86_64(t *testing.T) { if err != nil { t.Fatalf("EstimateLambda: %v", err) } - want := 5 * 1.0 * HoursPerMonth * 0.015 // 54.75 - if math.Abs(est.MonthlyUSD-want) > 1e-6 { + want := 5 * 1.0 * SecondsPerMonth * 0.0000041667 // ~54.75 + if math.Abs(est.MonthlyUSD-want) > 1e-3 { t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) } if est.Confidence != ConfidenceLow { t.Errorf("Confidence = %q, want low", est.Confidence) } - if got := src.calls[0].filters["architecture"]; got != "x86" { - t.Errorf("architecture filter = %q, want x86", got) + if got := src.calls[0].filters["usagetype"]; got != "USE2-Lambda-Provisioned-Concurrency" { + t.Errorf("usagetype = %q, want USE2-Lambda-Provisioned-Concurrency", got) } - if got := src.calls[0].filters["productFamily"]; got != "Provisioned Concurrency" { - t.Errorf("productFamily = %q", got) + if got := src.calls[0].filters["productFamily"]; got != "Serverless" { + t.Errorf("productFamily = %q, want Serverless", got) } if got := src.calls[0].service; got != "AWSLambda" { t.Errorf("service = %q, want AWSLambda", got) @@ -93,12 +90,12 @@ func TestEstimateLambda_ProvisionedConcurrency_ARM64(t *testing.T) { if err != nil { t.Fatalf("EstimateLambda: %v", err) } - want := 10 * 0.5 * HoursPerMonth * 0.012 // 43.8 - if math.Abs(est.MonthlyUSD-want) > 1e-6 { + want := 10 * 0.5 * SecondsPerMonth * 0.0000033334 // ~43.80 + if math.Abs(est.MonthlyUSD-want) > 1e-3 { t.Errorf("MonthlyUSD = %v, want %v", est.MonthlyUSD, want) } - if got := src.calls[0].filters["architecture"]; got != "ARM" { - t.Errorf("architecture filter = %q, want ARM", got) + if got := src.calls[0].filters["usagetype"]; got != "USE2-Lambda-Provisioned-Concurrency-ARM" { + t.Errorf("usagetype = %q, want -ARM suffix", got) } } @@ -150,7 +147,7 @@ func TestEstimateLambda_NoProducts(t *testing.T) { func TestEstimateLambda_BadUnit(t *testing.T) { body := strings.Replace( loadFixture(t, "lambda_arm64_us_east_2.json"), - `"unit": "GB-Hour"`, + `"unit": "Lambda-GB-Second"`, `"unit": "Hrs"`, 1, ) @@ -162,23 +159,39 @@ func TestEstimateLambda_BadUnit(t *testing.T) { ProvisionedConcurrency: 1, } _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") - if err == nil || !strings.Contains(err.Error(), "expected PC unit GB-Hour") { + if err == nil || !strings.Contains(err.Error(), "expected PC unit Lambda-GB-Second") { t.Fatalf("err = %v", err) } } -func TestMapLambdaArchitecture(t *testing.T) { +func TestEstimateLambda_AmbiguousQueryErrors(t *testing.T) { + body := loadFixture(t, "lambda_arm64_us_east_2.json") + second := strings.Replace(body, `"USD": "0.0000033334"`, `"USD": "0.99"`, 1) + src := &scriptedGetter{responses: [][]string{{body, second}}} + attrs := &aws.LambdaAttributes{ + FunctionName: "f", + MemorySize: 512, + Architecture: "arm64", + ProvisionedConcurrency: 1, + } + _, err := EstimateLambda(context.Background(), src, attrs, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "filter under-constrained") { + t.Fatalf("err = %v, want under-constrained error", err) + } +} + +func TestLambdaArchitectureSuffix(t *testing.T) { cases := []struct { in, want string err bool }{ - {"x86_64", "x86", false}, - {"", "x86", false}, - {"arm64", "ARM", false}, + {"x86_64", "", false}, + {"", "", false}, + {"arm64", "-ARM", false}, {"weird", "", true}, } for _, c := range cases { - got, err := mapLambdaArchitecture(c.in) + got, err := lambdaArchitectureSuffix(c.in) if c.err { if err == nil { t.Errorf("input %q: expected error", c.in) @@ -193,3 +206,28 @@ func TestMapLambdaArchitecture(t *testing.T) { } } } + +func TestRegionPrefix_Known(t *testing.T) { + cases := map[string]string{ + "us-east-1": "USE1", + "us-east-2": "USE2", + "us-west-1": "USW1", + "us-west-2": "USW2", + "eu-west-1": "EUW1", + "eu-central-1": "EUC1", + "ap-southeast-1": "APS1", + "ap-northeast-1": "APN1", + } + for region, want := range cases { + if got := regionPrefix(region); got != want { + t.Errorf("regionPrefix(%q) = %q, want %q", region, got, want) + } + } +} + +func TestRegionPrefix_UnknownFallsBackToInput(t *testing.T) { + got := regionPrefix("af-south-1") + if got != "af-south-1" { + t.Errorf("regionPrefix(af-south-1) = %q, want literal fallback", got) + } +} diff --git a/internal/pricing/nat.go b/internal/pricing/nat.go index 4dcd7a0..c48d548 100644 --- a/internal/pricing/nat.go +++ b/internal/pricing/nat.go @@ -3,7 +3,6 @@ package pricing import ( "context" "fmt" - "log/slog" "CloudOracle/internal/iac/aws" ) @@ -48,10 +47,8 @@ func EstimateNATGateway(ctx context.Context, src productGetter, attrs *aws.NATGa return Estimate{}, fmt.Errorf("EstimateNATGateway: no NAT Gateway price found in %s", region) } if len(products) > 1 { - slog.Warn("pricing: NAT Gateway query returned multiple products; using first", - "region", region, - "count", len(products), - ) + return Estimate{}, fmt.Errorf("EstimateNATGateway: query returned %d products; filter under-constrained for region=%s", + len(products), region) } hourly, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { diff --git a/internal/pricing/rds.go b/internal/pricing/rds.go index 0429b97..6690b66 100644 --- a/internal/pricing/rds.go +++ b/internal/pricing/rds.go @@ -3,7 +3,6 @@ package pricing import ( "context" "fmt" - "log/slog" "strings" "CloudOracle/internal/iac/aws" @@ -72,7 +71,7 @@ func EstimateRDS(ctx context.Context, src productGetter, attrs *aws.RDSAttribute if err != nil { return Estimate{}, err } - storage, err := lookupRDSStoragePrice(ctx, src, storageVolType, deployment, region, attrs.AllocatedStorage) + storage, err := lookupRDSStoragePrice(ctx, src, storageVolType, dbEngine, deployment, region, attrs.AllocatedStorage) if err != nil { return Estimate{}, err } @@ -118,13 +117,8 @@ func lookupRDSComputePrice(ctx context.Context, src productGetter, instanceClass return 0, fmt.Errorf("EstimateRDS: no compute price found for %s/%s/%s in %s", instanceClass, dbEngine, deployment, region) } if len(products) > 1 { - slog.Warn("pricing: RDS compute query returned multiple products; using first", - "instanceClass", instanceClass, - "engine", dbEngine, - "deployment", deployment, - "region", region, - "count", len(products), - ) + return 0, fmt.Errorf("EstimateRDS: compute query returned %d products; filter under-constrained for instanceClass=%s engine=%s deployment=%s region=%s", + len(products), instanceClass, dbEngine, deployment, region) } hourly, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { @@ -141,10 +135,18 @@ func lookupRDSComputePrice(ctx context.Context, src productGetter, instanceClass // Note that RDS uses a different filter vocabulary than EC2/EBS: the // filter name is volumeType (not volumeApiName) and the values are the // long-form names produced by mapRDSStorageType. -func lookupRDSStoragePrice(ctx context.Context, src productGetter, storageVolType, deployment, region string, sizeGB int) (float64, error) { +// +// databaseEngine is required to disambiguate the storage SKU. AWS +// catalogues a separate Database Storage row per supported engine +// (PostgreSQL, MySQL, MariaDB, Oracle×N editions, SQL Server×N editions, +// "Any") even though the per-GB price is currently identical across the +// OSS engines. Filtering without it returns ~15 products in us-east-2, +// which is what motivated this milestone's tightening. +func lookupRDSStoragePrice(ctx context.Context, src productGetter, storageVolType, dbEngine, deployment, region string, sizeGB int) (float64, error) { filters := map[string]string{ "productFamily": "Database Storage", "volumeType": storageVolType, + "databaseEngine": dbEngine, "deploymentOption": deployment, "regionCode": region, } @@ -153,15 +155,11 @@ func lookupRDSStoragePrice(ctx context.Context, src productGetter, storageVolTyp return 0, fmt.Errorf("EstimateRDS: storage lookup: %w", err) } if len(products) == 0 { - return 0, fmt.Errorf("EstimateRDS: no storage price found for %s/%s in %s", storageVolType, deployment, region) + return 0, fmt.Errorf("EstimateRDS: no storage price found for %s/%s/%s in %s", storageVolType, dbEngine, deployment, region) } if len(products) > 1 { - slog.Warn("pricing: RDS storage query returned multiple products; using first", - "volumeType", storageVolType, - "deployment", deployment, - "region", region, - "count", len(products), - ) + return 0, fmt.Errorf("EstimateRDS: storage query returned %d products; filter under-constrained for volumeType=%s engine=%s deployment=%s region=%s", + len(products), storageVolType, dbEngine, deployment, region) } gbMo, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { diff --git a/internal/pricing/rds_cluster_instance.go b/internal/pricing/rds_cluster_instance.go index 8fded54..8a8aede 100644 --- a/internal/pricing/rds_cluster_instance.go +++ b/internal/pricing/rds_cluster_instance.go @@ -3,7 +3,6 @@ package pricing import ( "context" "fmt" - "log/slog" "CloudOracle/internal/iac/aws" ) @@ -30,6 +29,12 @@ import ( // more aws_rds_cluster_instance resources, not by toggling a flag. // 2. licenseModel = "No license required". Aurora doesn't charge a // license fee on top of the compute rate. +// 3. storage = "EBS Only" (standard Aurora compute pricing). Aurora's +// I/O Optimization mode is exposed as a second SKU +// ("Aurora IO Optimization Mode", ~30% higher per-hour rate that +// waives per-I/O charges); supporting it would require a cluster- +// level attribute we don't currently extract, so we hard-code the +// standard mode and document the trade-off here. // // Returns an error for nil attrs, empty region/Engine/InstanceClass, // unsupported engines, API failures, missing products, or unit @@ -60,6 +65,7 @@ func EstimateRDSClusterInstance(ctx context.Context, src productGetter, attrs *a "regionCode": region, "deploymentOption": "Single-AZ", "licenseModel": "No license required", + "storage": "EBS Only", } products, err := src.GetProducts(ctx, "AmazonRDS", filters) if err != nil { @@ -69,12 +75,8 @@ func EstimateRDSClusterInstance(ctx context.Context, src productGetter, attrs *a return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: no compute price found for %s/%s in %s", attrs.InstanceClass, dbEngine, region) } if len(products) > 1 { - slog.Warn("pricing: Aurora cluster instance query returned multiple products; using first", - "instanceClass", attrs.InstanceClass, - "engine", dbEngine, - "region", region, - "count", len(products), - ) + return Estimate{}, fmt.Errorf("EstimateRDSClusterInstance: query returned %d products; filter under-constrained for instanceClass=%s engine=%s region=%s", + len(products), attrs.InstanceClass, dbEngine, region) } hourly, unit, err := parseOnDemandPriceUSD(products[0]) if err != nil { @@ -93,6 +95,7 @@ func EstimateRDSClusterInstance(ctx context.Context, src productGetter, attrs *a Notes: []string{ "Cluster-level storage and I/O charges not included (priced at aws_rds_cluster)", "Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag", + "Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled", }, }, nil } diff --git a/internal/pricing/regions.go b/internal/pricing/regions.go new file mode 100644 index 0000000..2e41365 --- /dev/null +++ b/internal/pricing/regions.go @@ -0,0 +1,48 @@ +package pricing + +import ( + "log/slog" +) + +// regionPrefix maps an AWS regionCode to the prefix used in the Pricing +// API's usagetype values. Pattern observed: us-east-2 → "USE2", +// eu-west-1 → "EUW1", ap-northeast-1 → "APN1". +// +// AWS does not expose this mapping in any public API — it lives in the +// billing schema only — so it is hard-coded here. The list covers the +// regions most commonly used by the kind of teams that look at PR cost +// diffs; gov-cloud, China, and the more exotic regions are out of scope. +// Unknown regions emit a slog.Warn and fall back to returning the region +// string unchanged. That fallback is rarely catastrophic because most +// queries that need a usagetype filter also carry at least one other +// discriminator (engine, instance type), so the query still narrows +// correctly enough to surface a clear "no products found" error. +// +// Currently only EstimateLambda's Provisioned Concurrency lookup uses +// this — usagetype is the only filter that disambiguates the x86 and +// arm64 PC SKUs (AWS does not expose `architecture` as an attribute on +// those products). +func regionPrefix(region string) string { + switch region { + case "us-east-1": + return "USE1" + case "us-east-2": + return "USE2" + case "us-west-1": + return "USW1" + case "us-west-2": + return "USW2" + case "eu-west-1": + return "EUW1" + case "eu-central-1": + return "EUC1" + case "ap-southeast-1": + return "APS1" + case "ap-northeast-1": + return "APN1" + } + slog.Warn("pricing: no usagetype prefix mapped for region; using region literal as fallback", + "region", region, + ) + return region +} diff --git a/internal/pricing/testdata/lambda_arm64_us_east_2.json b/internal/pricing/testdata/lambda_arm64_us_east_2.json index 4dcb058..536c6c2 100644 --- a/internal/pricing/testdata/lambda_arm64_us_east_2.json +++ b/internal/pricing/testdata/lambda_arm64_us_east_2.json @@ -1,29 +1,35 @@ { "product": { - "productFamily": "Provisioned Concurrency", + "productFamily": "Serverless", "attributes": { "regionCode": "us-east-2", - "architecture": "ARM", - "servicename": "AWS Lambda" + "servicecode": "AWSLambda", + "groupDescription": "Concurrency weighted by memory assigned to function over the period for which it is provisioned, measured in GB-s for ARM", + "usagetype": "USE2-Lambda-Provisioned-Concurrency-ARM", + "locationType": "AWS Region", + "location": "US East (Ohio)", + "servicename": "AWS Lambda", + "operation": "", + "group": "AWS-Lambda-Provisioned-Concurrency-ARM" }, - "sku": "LAMBDAPCARMUSE2" + "sku": "U6QUQCGH788KUY5N" }, "serviceCode": "AWSLambda", "terms": { "OnDemand": { - "LAMBDAPCARMUSE2.JRTCKXETXF": { + "U6QUQCGH788KUY5N.JRTCKXETXF": { "priceDimensions": { - "LAMBDAPCARMUSE2.JRTCKXETXF.6YS6EN2CT7": { - "unit": "GB-Hour", + "U6QUQCGH788KUY5N.JRTCKXETXF.6YS6EN2CT7": { + "unit": "Lambda-GB-Second", "endRange": "Inf", - "description": "$0.012 per GB-Hour for ARM64 Provisioned Concurrency", + "description": "AWS Lambda - Provisioned Concurrency for ARM - US East (Ohio)", "appliesTo": [], - "rateCode": "LAMBDAPCARMUSE2.JRTCKXETXF.6YS6EN2CT7", + "rateCode": "U6QUQCGH788KUY5N.JRTCKXETXF.6YS6EN2CT7", "beginRange": "0", - "pricePerUnit": {"USD": "0.012"} + "pricePerUnit": {"USD": "0.0000033334"} } }, - "sku": "LAMBDAPCARMUSE2", + "sku": "U6QUQCGH788KUY5N", "effectiveDate": "2024-01-01T00:00:00Z", "offerTermCode": "JRTCKXETXF", "termAttributes": {} From 8e3b296a58a0281e4ae74b197375f5da47886d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 17:03:16 -0400 Subject: [PATCH 10/21] feat: add markdown templates for various cloud cost impact scenarios --- internal/diff/engine.go | 264 +++++++++ internal/diff/engine_test.go | 505 +++++++++++++++++ internal/diff/markdown.go | 495 +++++++++++++++++ internal/diff/markdown_test.go | 514 ++++++++++++++++++ .../diff/testdata/markdown_all_skipped.md | 27 + internal/diff/testdata/markdown_empty_plan.md | 21 + internal/diff/testdata/markdown_happy_path.md | 55 ++ .../diff/testdata/markdown_mixed_actions.md | 47 ++ .../diff/testdata/markdown_net_decrease.md | 35 ++ internal/diff/testdata/markdown_net_zero.md | 30 + .../markdown_with_estimation_errors.md | 36 ++ internal/diff/types.go | 92 ++++ internal/diff/types_test.go | 28 + 13 files changed, 2149 insertions(+) create mode 100644 internal/diff/engine.go create mode 100644 internal/diff/engine_test.go create mode 100644 internal/diff/markdown.go create mode 100644 internal/diff/markdown_test.go create mode 100644 internal/diff/testdata/markdown_all_skipped.md create mode 100644 internal/diff/testdata/markdown_empty_plan.md create mode 100644 internal/diff/testdata/markdown_happy_path.md create mode 100644 internal/diff/testdata/markdown_mixed_actions.md create mode 100644 internal/diff/testdata/markdown_net_decrease.md create mode 100644 internal/diff/testdata/markdown_net_zero.md create mode 100644 internal/diff/testdata/markdown_with_estimation_errors.md create mode 100644 internal/diff/types.go create mode 100644 internal/diff/types_test.go diff --git a/internal/diff/engine.go b/internal/diff/engine.go new file mode 100644 index 0000000..67f79db --- /dev/null +++ b/internal/diff/engine.go @@ -0,0 +1,264 @@ +package diff + +import ( + "context" + "fmt" + "log/slog" + "math" + "sort" + "strings" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// TopMoversCount is the default number of top-by-absolute-delta items +// surfaced in CostDiff.TopMovers. Five fits in a PR comment header +// without crowding it; the renderer in 14.2 may show more on demand. +const TopMoversCount = 5 + +// Analyze runs pricing.EstimateChange on every resource change in the +// plan and aggregates the results into a CostDiff. +// +// Failures on individual changes are NOT fatal: the orchestrator logs +// them at slog.Warn and the change is added to Skipped with +// SkipReason="estimation failed: ". This way callers can always +// render a CostDiff — even one where every resource failed to price — +// rather than refusing to produce a comment because of a single +// transient API hiccup. +// +// Analyze returns a non-nil error only when the input itself is +// malformed: nil plan, nil src, empty region. Multi-region plans are +// out of scope; all changes are priced against `region`. +func Analyze(ctx context.Context, src Source, plan *iac.Plan, region string) (CostDiff, error) { + if src == nil { + return CostDiff{}, fmt.Errorf("Analyze: nil src") + } + return analyzeWithEstimator(ctx, src, plan, region, defaultEstimator) +} + +// estimator is the function shape Analyze uses internally to produce +// per-change estimates. Defaulted to pricing.EstimateChange via +// defaultEstimator; tests inject their own to avoid building a fake +// Source plus fixture JSON for every assertion. +type estimator func(ctx context.Context, src Source, rc iac.ResourceChange, region string) (pricing.ChangeEstimate, error) + +// defaultEstimator is the production wiring: a thin pass-through to +// pricing.EstimateChange. Lives as a named function rather than a +// closure so the test seam (analyzeWithEstimator) is easy to read. +func defaultEstimator(ctx context.Context, src Source, rc iac.ResourceChange, region string) (pricing.ChangeEstimate, error) { + return pricing.EstimateChange(ctx, src, rc, region) +} + +// analyzeWithEstimator is the test seam. Public Analyze always supplies +// defaultEstimator; tests pass a fake to avoid simulating Pricing API +// JSON. Mirrors the pattern pricing.newClientWithAPI uses. +func analyzeWithEstimator(ctx context.Context, src Source, plan *iac.Plan, region string, est estimator) (CostDiff, error) { + if plan == nil { + return CostDiff{}, fmt.Errorf("Analyze: nil plan") + } + if region == "" { + return CostDiff{}, fmt.Errorf("Analyze: empty region") + } + + out := CostDiff{ + Currency: "USD", + Changes: make([]pricing.ChangeEstimate, 0, len(plan.ResourceChanges)), + } + + for _, rc := range plan.ResourceChanges { + ce, err := est(ctx, src, rc, region) + if err != nil { + slog.Warn("diff: estimation failed; recording as skipped", + "address", rc.Address, + "type", rc.Type, + "error", err, + ) + ce = pricing.ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: rc.Action(), + Currency: "USD", + Confidence: pricing.ConfidenceHigh, + Skipped: true, + SkipReason: "estimation failed: " + err.Error(), + } + } + out.Changes = append(out.Changes, ce) + } + + // Sort by absolute MonthlyDelta descending. Skipped items have + // delta=0 and naturally trail the priced ones; ties keep input order + // (sort.SliceStable would matter only if we cared about secondary + // ordering, which we don't here). + sort.SliceStable(out.Changes, func(i, j int) bool { + return math.Abs(out.Changes[i].MonthlyDelta) > math.Abs(out.Changes[j].MonthlyDelta) + }) + + out.Confidence = pricing.ConfidenceHigh + for i := range out.Changes { + ce := &out.Changes[i] + out.TotalMonthlyDelta += ce.MonthlyDelta + categorize(ce, &out) + if !ce.Skipped { + out.Confidence = weakestConfidence(out.Confidence, ce.Confidence) + } + } + + out.TopMovers = topMovers(out.Changes, TopMoversCount) + out.Stats = computeStats(&out) + out.Notes = buildNotes(&out) + + return out, nil +} + +// categorize files a change into the appropriate action-keyed slice on +// out, falling back to Skipped when ce.Skipped is set regardless of +// action. The action-keyed slices intentionally exclude skipped items +// so a renderer iterating "Created" sees only successfully-priced +// creates. +func categorize(ce *pricing.ChangeEstimate, out *CostDiff) { + if ce.Skipped { + out.Skipped = append(out.Skipped, *ce) + return + } + switch classifyAction(ce.Action) { + case "create": + out.Created = append(out.Created, *ce) + case "delete": + out.Deleted = append(out.Deleted, *ce) + case "update": + out.Updated = append(out.Updated, *ce) + case "replace": + out.Replaced = append(out.Replaced, *ce) + } +} + +// classifyAction returns the canonical category string for an action, +// or "" for actions that don't get their own slice (no-op, read, +// unknown). The empty string is the cue to NOT add the change to any +// action-specific slice — those changes are still represented in +// Changes, and if Skipped=true they show up in Skipped. +func classifyAction(a iac.Action) string { + switch a { + case iac.ActionCreate: + return "create" + case iac.ActionDelete: + return "delete" + case iac.ActionUpdate: + return "update" + case iac.ActionReplace: + return "replace" + } + return "" +} + +// topMovers returns the first n non-skipped entries from changes. +// Skipped items have delta=0 and would dilute the "biggest changes" +// summary, so they are filtered out. n is clamped against the +// available non-skipped count. +func topMovers(changes []pricing.ChangeEstimate, n int) []pricing.ChangeEstimate { + if n <= 0 || len(changes) == 0 { + return nil + } + out := make([]pricing.ChangeEstimate, 0, n) + for _, c := range changes { + if c.Skipped { + continue + } + out = append(out, c) + if len(out) == n { + break + } + } + if len(out) == 0 { + return nil + } + return out +} + +// computeStats counts each disjoint category. The partition rule is in +// the Stats godoc: NoOp captures no-op/read actions; Skipped captures +// items with Skipped=true that AREN'T no-op/read; Priced is the four +// action slices summed. +func computeStats(out *CostDiff) Stats { + s := Stats{ + Total: len(out.Changes), + Created: len(out.Created), + Deleted: len(out.Deleted), + Updated: len(out.Updated), + Replaced: len(out.Replaced), + } + s.Priced = s.Created + s.Deleted + s.Updated + s.Replaced + for _, c := range out.Changes { + switch c.Action { + case iac.ActionNoop, iac.ActionRead: + s.NoOp++ + default: + if c.Skipped { + s.Skipped++ + } + } + } + return s +} + +// buildNotes produces the plan-wide notes appended to CostDiff.Notes. +// The order is fixed: skip-count breakdown first (if any skips), then +// either the "no priceable" diagnostic or the net-direction note. +// Calling code can rely on this order when rendering. +func buildNotes(out *CostDiff) []string { + var notes []string + + if len(out.Skipped) > 0 { + var unsupported, estFail int + for _, c := range out.Skipped { + switch { + case strings.Contains(c.SkipReason, "unsupported"): + unsupported++ + case strings.Contains(c.SkipReason, "estimation failed"): + estFail++ + } + } + notes = append(notes, + fmt.Sprintf("%d resources skipped (%d unsupported types, %d estimation failures)", + len(out.Skipped), unsupported, estFail)) + } + + allSkipped := len(out.Changes) == 0 || len(out.Skipped) == len(out.Changes) + switch { + case allSkipped: + notes = append(notes, "No priceable resources in plan") + case out.TotalMonthlyDelta > 0: + notes = append(notes, "Net cost increase this plan") + case out.TotalMonthlyDelta < 0: + notes = append(notes, "Net cost reduction this plan") + default: + notes = append(notes, "Net zero cost change") + } + + return notes +} + +// weakestConfidence returns whichever of a/b is "weaker": low dominates +// medium dominates high. Duplicated from pricing/change.go intentionally +// — the function is five lines and importing it would couple the diff +// package to pricing's confidenceRank, which is unexported and could +// change shape independently. +func weakestConfidence(a, b pricing.Confidence) pricing.Confidence { + rank := func(c pricing.Confidence) int { + switch c { + case pricing.ConfidenceHigh: + return 0 + case pricing.ConfidenceMedium: + return 1 + case pricing.ConfidenceLow: + return 2 + } + return 3 + } + if rank(a) >= rank(b) { + return a + } + return b +} diff --git a/internal/diff/engine_test.go b/internal/diff/engine_test.go new file mode 100644 index 0000000..8555d0d --- /dev/null +++ b/internal/diff/engine_test.go @@ -0,0 +1,505 @@ +package diff + +import ( + "context" + "errors" + "math" + "strings" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// fakeEstimator is the test seam for analyzeWithEstimator. It looks up +// the response by rc.Address; tests build a results map and (optionally) +// an errors map keyed by address. nilSrc satisfies Source for callers +// that don't exercise the underlying call path. +type fakeEstimator struct { + results map[string]pricing.ChangeEstimate + errors map[string]error +} + +func (f *fakeEstimator) estimate(_ context.Context, _ Source, rc iac.ResourceChange, _ string) (pricing.ChangeEstimate, error) { + if err, ok := f.errors[rc.Address]; ok { + return pricing.ChangeEstimate{}, err + } + if r, ok := f.results[rc.Address]; ok { + return r, nil + } + // Default: a no-op-ish skipped result so tests don't crash on + // missing entries — they'll see the missing address in the diff. + return pricing.ChangeEstimate{ + ResourceAddress: rc.Address, + ResourceType: rc.Type, + Action: rc.Action(), + Skipped: true, + SkipReason: "no result programmed for " + rc.Address, + }, nil +} + +// nilSource is a Source that should never be called (tests inject a +// fake estimator that ignores src). Calling GetProducts panics so a +// regression in the wiring is loud. +type nilSource struct{} + +func (nilSource) GetProducts(_ context.Context, _ string, _ map[string]string) ([]string, error) { + panic("nilSource.GetProducts called — fake estimator should bypass src entirely") +} + +// rc is a small constructor for ResourceChange test fixtures. +func rc(addr, typ string, action string) iac.ResourceChange { + return iac.ResourceChange{ + Address: addr, + Mode: "managed", + Type: typ, + Change: iac.Change{Actions: []string{action}}, + } +} + +func plan(rcs ...iac.ResourceChange) *iac.Plan { + return &iac.Plan{ + FormatVersion: "1.2", + ResourceChanges: rcs, + } +} + +func TestAnalyze_HappyPath(t *testing.T) { + rcs := []iac.ResourceChange{ + rc("aws_instance.web", "aws_instance", "create"), + rc("aws_ebs_volume.vol", "aws_ebs_volume", "delete"), + rc("aws_db_instance.db", "aws_db_instance", "update"), + rc("aws_lambda_function.fn", "aws_lambda_function", "create"), + } + // Replace = ["delete","create"] + rcs = append(rcs, iac.ResourceChange{ + Address: "aws_lambda_function.replaced", + Mode: "managed", + Type: "aws_lambda_function", + Change: iac.Change{Actions: []string{"delete", "create"}}, + }) + p := plan(rcs...) + + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionCreate, Currency: "USD", + AfterMonthly: 100, MonthlyDelta: 100, Confidence: pricing.ConfidenceLow, + }, + "aws_ebs_volume.vol": { + ResourceAddress: "aws_ebs_volume.vol", ResourceType: "aws_ebs_volume", + Action: iac.ActionDelete, Currency: "USD", + BeforeMonthly: 5, MonthlyDelta: -5, Confidence: pricing.ConfidenceMedium, + }, + "aws_db_instance.db": { + ResourceAddress: "aws_db_instance.db", ResourceType: "aws_db_instance", + Action: iac.ActionUpdate, Currency: "USD", + BeforeMonthly: 50, AfterMonthly: 75, MonthlyDelta: 25, Confidence: pricing.ConfidenceLow, + }, + "aws_lambda_function.fn": { + ResourceAddress: "aws_lambda_function.fn", ResourceType: "aws_lambda_function", + Action: iac.ActionCreate, Currency: "USD", + AfterMonthly: 0, MonthlyDelta: 0, Confidence: pricing.ConfidenceLow, + }, + "aws_lambda_function.replaced": { + ResourceAddress: "aws_lambda_function.replaced", ResourceType: "aws_lambda_function", + Action: iac.ActionReplace, Currency: "USD", + BeforeMonthly: 10, AfterMonthly: 30, MonthlyDelta: 20, Confidence: pricing.ConfidenceLow, + }, + }} + + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("analyzeWithEstimator: %v", err) + } + + wantTotal := 100.0 - 5 + 25 + 0 + 20 + if math.Abs(d.TotalMonthlyDelta-wantTotal) > 1e-9 { + t.Errorf("TotalMonthlyDelta = %v, want %v", d.TotalMonthlyDelta, wantTotal) + } + if d.Currency != "USD" { + t.Errorf("Currency = %q", d.Currency) + } + + // Sort: 100, 25, 20, -5, 0 (by abs) + wantOrder := []string{ + "aws_instance.web", "aws_db_instance.db", "aws_lambda_function.replaced", + "aws_ebs_volume.vol", "aws_lambda_function.fn", + } + for i, want := range wantOrder { + if d.Changes[i].ResourceAddress != want { + t.Errorf("Changes[%d] = %q, want %q", i, d.Changes[i].ResourceAddress, want) + } + } + + if len(d.Created) != 2 { + t.Errorf("Created len = %d, want 2", len(d.Created)) + } + if len(d.Deleted) != 1 { + t.Errorf("Deleted len = %d, want 1", len(d.Deleted)) + } + if len(d.Updated) != 1 { + t.Errorf("Updated len = %d, want 1", len(d.Updated)) + } + if len(d.Replaced) != 1 { + t.Errorf("Replaced len = %d, want 1", len(d.Replaced)) + } + if len(d.Skipped) != 0 { + t.Errorf("Skipped len = %d, want 0", len(d.Skipped)) + } + + // TopMovers: first 5 non-skipped, in delta order. + if len(d.TopMovers) != 5 { + t.Errorf("TopMovers len = %d, want 5", len(d.TopMovers)) + } + + // Confidence is the weakest non-skipped: Low (4 Low + 1 Medium). + if d.Confidence != pricing.ConfidenceLow { + t.Errorf("Confidence = %q, want low", d.Confidence) + } +} + +func TestAnalyze_AllSkipped(t *testing.T) { + p := plan( + rc("aws_iam_role.r1", "aws_iam_role", "create"), + rc("aws_iam_role.r2", "aws_iam_role", "create"), + rc("aws_iam_role.r3", "aws_iam_role", "delete"), + ) + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, ResourceType: "aws_iam_role", Currency: "USD", + Action: iac.ActionCreate, Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + } + } + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_iam_role.r1": mk("aws_iam_role.r1"), + "aws_iam_role.r2": mk("aws_iam_role.r2"), + "aws_iam_role.r3": mk("aws_iam_role.r3"), + }} + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if d.TotalMonthlyDelta != 0 { + t.Errorf("TotalMonthlyDelta = %v, want 0", d.TotalMonthlyDelta) + } + if d.Confidence != pricing.ConfidenceHigh { + t.Errorf("Confidence = %q, want high (no priceable changes)", d.Confidence) + } + if len(d.Skipped) != 3 { + t.Errorf("Skipped len = %d, want 3", len(d.Skipped)) + } + if len(d.TopMovers) != 0 { + t.Errorf("TopMovers len = %d, want 0", len(d.TopMovers)) + } + foundNote := false + for _, n := range d.Notes { + if strings.Contains(n, "No priceable resources") { + foundNote = true + } + } + if !foundNote { + t.Errorf("Notes missing 'No priceable resources': %v", d.Notes) + } +} + +func TestAnalyze_EstimationError(t *testing.T) { + p := plan( + rc("aws_instance.web", "aws_instance", "create"), + rc("aws_instance.broken", "aws_instance", "create"), + ) + fake := &fakeEstimator{ + results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionCreate, AfterMonthly: 50, MonthlyDelta: 50, + Confidence: pricing.ConfidenceLow, Currency: "USD", + }, + }, + errors: map[string]error{ + "aws_instance.broken": errors.New("AccessDenied"), + }, + } + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + // The successful one is in Created; the broken one is in Skipped. + if len(d.Created) != 1 { + t.Errorf("Created len = %d, want 1", len(d.Created)) + } + if len(d.Skipped) != 1 { + t.Errorf("Skipped len = %d, want 1", len(d.Skipped)) + } + if got := d.Skipped[0].SkipReason; !strings.Contains(got, "estimation failed") || !strings.Contains(got, "AccessDenied") { + t.Errorf("SkipReason = %q, missing 'estimation failed'/'AccessDenied'", got) + } + if d.Skipped[0].ResourceAddress != "aws_instance.broken" { + t.Errorf("Skipped[0].Address = %q", d.Skipped[0].ResourceAddress) + } + + // Plan-wide note breaks down the skip reasons. + foundBreakdown := false + for _, n := range d.Notes { + if strings.Contains(n, "estimation failures") && strings.Contains(n, "1 estimation failures") { + foundBreakdown = true + } + } + if !foundBreakdown { + t.Errorf("Notes missing skip-breakdown: %v", d.Notes) + } +} + +func TestAnalyze_NoOp(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "no-op")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", ResourceType: "aws_instance", + Action: iac.ActionNoop, Currency: "USD", + Skipped: true, SkipReason: "action has no cost impact", + Confidence: pricing.ConfidenceHigh, + }, + }} + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(d.Created) != 0 || len(d.Updated) != 0 { + t.Errorf("no-op should not appear in priced slices: created=%d updated=%d", len(d.Created), len(d.Updated)) + } + if len(d.Skipped) != 1 { + t.Errorf("Skipped len = %d, want 1", len(d.Skipped)) + } + if d.Stats.NoOp != 1 { + t.Errorf("Stats.NoOp = %d, want 1", d.Stats.NoOp) + } + if d.Stats.Skipped != 0 { + t.Errorf("Stats.Skipped = %d, want 0 (no-op is counted under NoOp, not Skipped)", d.Stats.Skipped) + } +} + +func TestAnalyze_NetIncrease(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "create")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionCreate, + AfterMonthly: 50, MonthlyDelta: 50, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net cost increase") { + t.Errorf("expected 'Net cost increase' note, got: %v", d.Notes) + } +} + +func TestAnalyze_NetDecrease(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "delete")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionDelete, + BeforeMonthly: 50, MonthlyDelta: -50, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net cost reduction") { + t.Errorf("expected 'Net cost reduction' note, got: %v", d.Notes) + } +} + +func TestAnalyze_NetZero(t *testing.T) { + p := plan(rc("aws_instance.web", "aws_instance", "update")) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.web": { + ResourceAddress: "aws_instance.web", Action: iac.ActionUpdate, + BeforeMonthly: 50, AfterMonthly: 50, MonthlyDelta: 0, Confidence: pricing.ConfidenceLow, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if !containsNote(d.Notes, "Net zero cost change") { + t.Errorf("expected 'Net zero cost change' note, got: %v", d.Notes) + } +} + +func TestAnalyze_TopMoversFiltersSkipped(t *testing.T) { + rcs := make([]iac.ResourceChange, 0, 10) + for i := 1; i <= 7; i++ { + rcs = append(rcs, rc("aws_instance."+string(rune('a'+i-1)), "aws_instance", "create")) + } + rcs = append(rcs, rc("aws_iam_role.r1", "aws_iam_role", "create")) + rcs = append(rcs, rc("aws_iam_role.r2", "aws_iam_role", "create")) + rcs = append(rcs, rc("aws_iam_role.r3", "aws_iam_role", "create")) + + results := map[string]pricing.ChangeEstimate{} + for i, r := range rcs { + isSkipped := r.Type == "aws_iam_role" + ce := pricing.ChangeEstimate{ + ResourceAddress: r.Address, ResourceType: r.Type, + Action: iac.ActionCreate, Currency: "USD", + Confidence: pricing.ConfidenceLow, + } + if isSkipped { + ce.Skipped = true + ce.SkipReason = "unsupported resource type: aws_iam_role" + } else { + ce.AfterMonthly = float64(i+1) * 10 + ce.MonthlyDelta = float64(i+1) * 10 + } + results[r.Address] = ce + } + fake := &fakeEstimator{results: results} + p := plan(rcs...) + + d, err := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if len(d.TopMovers) != TopMoversCount { + t.Errorf("TopMovers len = %d, want %d", len(d.TopMovers), TopMoversCount) + } + for _, m := range d.TopMovers { + if m.Skipped { + t.Errorf("TopMovers contains skipped entry: %+v", m) + } + } + // Ordered by abs delta descending: 70, 60, 50, 40, 30 + wantFirst := 70.0 + if math.Abs(d.TopMovers[0].MonthlyDelta-wantFirst) > 1e-9 { + t.Errorf("TopMovers[0].MonthlyDelta = %v, want %v", d.TopMovers[0].MonthlyDelta, wantFirst) + } +} + +func TestAnalyze_TopMoversCountClampedDown(t *testing.T) { + p := plan( + rc("aws_instance.a", "aws_instance", "create"), + rc("aws_instance.b", "aws_instance", "create"), + rc("aws_instance.c", "aws_instance", "create"), + ) + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.a": {ResourceAddress: "aws_instance.a", Action: iac.ActionCreate, MonthlyDelta: 10, Confidence: pricing.ConfidenceLow}, + "aws_instance.b": {ResourceAddress: "aws_instance.b", Action: iac.ActionCreate, MonthlyDelta: 20, Confidence: pricing.ConfidenceLow}, + "aws_instance.c": {ResourceAddress: "aws_instance.c", Action: iac.ActionCreate, MonthlyDelta: 30, Confidence: pricing.ConfidenceLow}, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + if len(d.TopMovers) != 3 { + t.Errorf("TopMovers len = %d, want 3 (only 3 changes exist)", len(d.TopMovers)) + } +} + +func TestAnalyze_NilPlan(t *testing.T) { + _, err := analyzeWithEstimator(context.Background(), nilSource{}, nil, "us-east-2", (&fakeEstimator{}).estimate) + if err == nil || !strings.Contains(err.Error(), "nil plan") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_EmptyRegion(t *testing.T) { + _, err := analyzeWithEstimator(context.Background(), nilSource{}, &iac.Plan{FormatVersion: "1.2"}, "", (&fakeEstimator{}).estimate) + if err == nil || !strings.Contains(err.Error(), "empty region") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_NilSrc(t *testing.T) { + _, err := Analyze(context.Background(), nil, &iac.Plan{FormatVersion: "1.2"}, "us-east-2") + if err == nil || !strings.Contains(err.Error(), "nil src") { + t.Fatalf("err = %v", err) + } +} + +func TestAnalyze_EmptyPlan(t *testing.T) { + d, err := analyzeWithEstimator(context.Background(), nilSource{}, &iac.Plan{FormatVersion: "1.2"}, "us-east-2", (&fakeEstimator{}).estimate) + if err != nil { + t.Fatalf("err: %v", err) + } + if d.Stats.Total != 0 { + t.Errorf("Stats.Total = %d, want 0", d.Stats.Total) + } + if d.Confidence != pricing.ConfidenceHigh { + t.Errorf("Confidence = %q, want high (no analysis to doubt)", d.Confidence) + } + for _, slice := range [][]pricing.ChangeEstimate{ + d.Changes, d.Created, d.Deleted, d.Updated, d.Replaced, d.Skipped, d.TopMovers, + } { + if len(slice) != 0 { + t.Errorf("expected empty slice, got %v", slice) + } + } + if !containsNote(d.Notes, "No priceable resources") { + t.Errorf("expected 'No priceable resources' note, got: %v", d.Notes) + } +} + +func TestStats_Counts(t *testing.T) { + p := plan( + rc("aws_instance.a", "aws_instance", "create"), + rc("aws_instance.b", "aws_instance", "delete"), + rc("aws_instance.c", "aws_instance", "update"), + iac.ResourceChange{ + Address: "aws_instance.d", Mode: "managed", Type: "aws_instance", + Change: iac.Change{Actions: []string{"delete", "create"}}, + }, + rc("aws_iam_role.r", "aws_iam_role", "create"), + rc("aws_instance.noop", "aws_instance", "no-op"), + ) + mkPriced := func(addr string, action iac.Action, delta float64) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, ResourceType: "aws_instance", Action: action, + MonthlyDelta: delta, Confidence: pricing.ConfidenceLow, + } + } + fake := &fakeEstimator{results: map[string]pricing.ChangeEstimate{ + "aws_instance.a": mkPriced("aws_instance.a", iac.ActionCreate, 100), + "aws_instance.b": mkPriced("aws_instance.b", iac.ActionDelete, -50), + "aws_instance.c": mkPriced("aws_instance.c", iac.ActionUpdate, 25), + "aws_instance.d": mkPriced("aws_instance.d", iac.ActionReplace, 10), + "aws_iam_role.r": { + ResourceAddress: "aws_iam_role.r", ResourceType: "aws_iam_role", + Action: iac.ActionCreate, Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + }, + "aws_instance.noop": { + ResourceAddress: "aws_instance.noop", ResourceType: "aws_instance", + Action: iac.ActionNoop, Skipped: true, + SkipReason: "action has no cost impact", Confidence: pricing.ConfidenceHigh, + }, + }} + d, _ := analyzeWithEstimator(context.Background(), nilSource{}, p, "us-east-2", fake.estimate) + + want := Stats{Total: 6, Created: 1, Deleted: 1, Updated: 1, Replaced: 1, NoOp: 1, Skipped: 1, Priced: 4} + if d.Stats != want { + t.Errorf("Stats = %+v, want %+v", d.Stats, want) + } + // Disjoint partition invariant. + if d.Stats.Priced+d.Stats.NoOp+d.Stats.Skipped != d.Stats.Total { + t.Errorf("partition broken: priced=%d noop=%d skipped=%d total=%d", + d.Stats.Priced, d.Stats.NoOp, d.Stats.Skipped, d.Stats.Total) + } +} + +func TestClassifyAction(t *testing.T) { + cases := map[iac.Action]string{ + iac.ActionCreate: "create", + iac.ActionDelete: "delete", + iac.ActionUpdate: "update", + iac.ActionReplace: "replace", + iac.ActionNoop: "", + iac.ActionRead: "", + } + for a, want := range cases { + if got := classifyAction(a); got != want { + t.Errorf("classifyAction(%q) = %q, want %q", a, got, want) + } + } +} + +func containsNote(notes []string, sub string) bool { + for _, n := range notes { + if strings.Contains(n, sub) { + return true + } + } + return false +} diff --git a/internal/diff/markdown.go b/internal/diff/markdown.go new file mode 100644 index 0000000..cfa6089 --- /dev/null +++ b/internal/diff/markdown.go @@ -0,0 +1,495 @@ +package diff + +import ( + "bytes" + "fmt" + "math" + "strings" + "text/template" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// MarkdownConfig customizes the Markdown rendering. The zero value is +// valid and produces the default presentation: full breakdown shown, +// caveats shown, the canonical comment marker, the project repo URL, +// and Analyze-supplied TopMovers. +// +// Polarity note on Hide* booleans. Spec called for "Show*" flags with +// a true default, but Go cannot distinguish an unset bool from an +// explicit `false`, so a Show* design would force every caller that +// constructs MarkdownConfig{} to also remember to set Show* = true. We +// inverted to Hide* so the zero value naturally means "show +// everything". Setting HideFullBreakdown / HideCaveats to true at the +// call site has the same effect a hypothetical Show*=false would. +type MarkdownConfig struct { + CommentMarker string + RepoURL string + HideFullBreakdown bool + HideCaveats bool + TopMoversCount int +} + +const ( + defaultCommentMarker = "cloudoracle-pr-v1" + defaultRepoURL = "https://github.com/Cro22/CloudOracle" + + // centavoTolerance is the threshold below which a delta is treated + // as zero for sign-and-emoji purposes. Pricing deltas accumulate + // floating-point noise — a "zero" change can land at $0.0000001 — + // and a 🔴 emoji on a fraction-of-a-cent rounding error makes the + // PR comment look broken to humans. Half a cent is well below + // "anything a human cares about" without false-zeroing genuine + // pennies. + centavoTolerance = 0.005 +) + +// RenderMarkdown returns the CostDiff rendered as a self-contained +// Markdown comment suitable for posting on a GitHub pull request. Uses +// the default MarkdownConfig. +func RenderMarkdown(d CostDiff) string { + return RenderMarkdownWithConfig(d, MarkdownConfig{}) +} + +// RenderMarkdownWithConfig is RenderMarkdown with explicit configuration. +// See MarkdownConfig for the supported knobs. +func RenderMarkdownWithConfig(d CostDiff, cfg MarkdownConfig) string { + cfg = applyDefaults(cfg) + data := buildTemplateData(d, cfg) + var buf bytes.Buffer + if err := mdTemplate.Execute(&buf, data); err != nil { + // Should never trigger — the template is a hard-coded constant + // vetted at init() via template.Must. If a future edit slips a + // regression past tests, surface the failure inline instead of + // silently emitting a half-rendered comment. + return fmt.Sprintf("CloudOracle render error: %v", err) + } + return buf.String() +} + +func applyDefaults(cfg MarkdownConfig) MarkdownConfig { + if cfg.CommentMarker == "" { + cfg.CommentMarker = defaultCommentMarker + } + if cfg.RepoURL == "" { + cfg.RepoURL = defaultRepoURL + } + return cfg +} + +// templateData is the precomputed shape passed to the Markdown +// template. Every dynamic value is preformatted here so the template +// body stays free of formatting logic — easier to reason about +// whitespace and easier to swap when milestone 15 introduces an +// LLM-generated narrative. +type templateData struct { + NetChange string + TrendEmoji string + Narrative string + HasTopMovers bool + TopMovers []tplRow + ShowFullBreakdown bool + StatsPriced int + StatsSkippedTotal int + HasCreated bool + Created []tplRow + HasDeleted bool + Deleted []tplRow + HasUpdated bool + Updated []tplRow + HasReplaced bool + Replaced []tplRow + HasSkipped bool + Skipped []tplSkipRow + ShowCaveats bool + GlobalNotes []string + PerResourceNotes []tplResourceNote + AggregateConfidence string + RepoURL string + CommentMarker string +} + +type tplRow struct { + Address string + Action string + Delta string + Confidence string + Breakdown []tplLine +} + +type tplLine struct { + Component string + Cost string +} + +type tplSkipRow struct { + Address string + Type string + SkipReason string +} + +type tplResourceNote struct { + Note string + Addresses []string +} + +func buildTemplateData(d CostDiff, cfg MarkdownConfig) templateData { + data := templateData{ + NetChange: formatDelta(d.TotalMonthlyDelta), + TrendEmoji: trendEmoji(d.TotalMonthlyDelta), + Narrative: renderNarrative(d), + ShowFullBreakdown: !cfg.HideFullBreakdown, + ShowCaveats: !cfg.HideCaveats, + StatsPriced: d.Stats.Priced, + StatsSkippedTotal: len(d.Skipped), + AggregateConfidence: string(d.Confidence), + RepoURL: cfg.RepoURL, + CommentMarker: cfg.CommentMarker, + } + + movers := selectTopMovers(d.TopMovers, cfg.TopMoversCount) + for _, m := range movers { + data.TopMovers = append(data.TopMovers, makeRow(m)) + } + data.HasTopMovers = len(data.TopMovers) > 0 + + for _, c := range d.Created { + data.Created = append(data.Created, makeRow(c)) + } + data.HasCreated = len(data.Created) > 0 + for _, c := range d.Deleted { + data.Deleted = append(data.Deleted, makeRow(c)) + } + data.HasDeleted = len(data.Deleted) > 0 + for _, c := range d.Updated { + data.Updated = append(data.Updated, makeRow(c)) + } + data.HasUpdated = len(data.Updated) > 0 + for _, c := range d.Replaced { + data.Replaced = append(data.Replaced, makeRow(c)) + } + data.HasReplaced = len(data.Replaced) > 0 + + for _, c := range d.Skipped { + data.Skipped = append(data.Skipped, tplSkipRow{ + Address: c.ResourceAddress, + Type: c.ResourceType, + SkipReason: c.SkipReason, + }) + } + data.HasSkipped = len(data.Skipped) > 0 + + data.GlobalNotes = append([]string(nil), d.Notes...) + data.PerResourceNotes = deduplicateNotes(d.Changes) + + return data +} + +// selectTopMovers honours cfg.TopMoversCount: 0 (default) means "use +// what Analyze gave us", positive caps the count, negative selects +// nothing (a foot-gun guard rather than a meaningful feature). +func selectTopMovers(in []pricing.ChangeEstimate, n int) []pricing.ChangeEstimate { + switch { + case n < 0: + return nil + case n == 0: + return in + case n > len(in): + return in + default: + return in[:n] + } +} + +func makeRow(c pricing.ChangeEstimate) tplRow { + row := tplRow{ + Address: c.ResourceAddress, + Action: actionDisplay(c.Action), + Delta: formatDelta(c.MonthlyDelta), + Confidence: string(c.Confidence), + } + for _, li := range c.Breakdown { + row.Breakdown = append(row.Breakdown, tplLine{ + Component: li.Component, + Cost: formatDelta(li.MonthlyUSD), + }) + } + return row +} + +// deduplicateNotes groups identical Notes texts across resources so a +// caveat list shared by N resources renders as one bullet instead of N. +// The order of unique notes follows first-seen-in-Changes; addresses +// inside each note follow first-seen order; both matter for stable +// golden tests. +func deduplicateNotes(changes []pricing.ChangeEstimate) []tplResourceNote { + byNote := map[string][]string{} + var order []string + for _, c := range changes { + for _, n := range c.Notes { + if _, ok := byNote[n]; !ok { + order = append(order, n) + } + // Addresses are deduped too — repeating the same address + // for the same note (which only happens via parser bugs) + // would clutter the rendered list silently. + addrs := byNote[n] + seen := false + for _, a := range addrs { + if a == c.ResourceAddress { + seen = true + break + } + } + if !seen { + byNote[n] = append(addrs, c.ResourceAddress) + } + } + } + out := make([]tplResourceNote, 0, len(order)) + for _, n := range order { + out = append(out, tplResourceNote{Note: n, Addresses: byNote[n]}) + } + return out +} + +// formatDelta renders a monetary value with explicit sign for a PR +// comment. Conventions: +// +// - positive → "+$60.74" +// - negative → "-$50.00" +// - near-zero → "$0.00" (no sign — applied within centavoTolerance to +// avoid surfacing floating-point noise as a real change) +// +// Always two decimals, always a dollar sign, never scientific notation. +func formatDelta(d float64) string { + if math.Abs(d) < centavoTolerance { + return "$0.00" + } + if d > 0 { + return fmt.Sprintf("+$%.2f", d) + } + return fmt.Sprintf("-$%.2f", math.Abs(d)) +} + +// trendEmoji returns the colored circle that pairs with the net delta +// in the comment header. Within centavoTolerance the change is treated +// as zero (⚪) — a sub-cent fluctuation should not draw a red dot. +func trendEmoji(d float64) string { + if math.Abs(d) < centavoTolerance { + return "⚪" + } + if d > 0 { + return "🔴" + } + return "🟢" +} + +// actionDisplay returns the label shown in the Top movers table for an +// action. The emojis are deliberately verbose — a PR reviewer scanning +// a long comment recognises 🔄 replace much faster than the bare word. +// Unknown action strings pass through verbatim with no emoji rather +// than masking a parser issue with a generic icon. +func actionDisplay(a iac.Action) string { + switch a { + case iac.ActionCreate: + return "🆕 create" + case iac.ActionDelete: + return "❌ delete" + case iac.ActionUpdate: + return "♻️ update" + case iac.ActionReplace: + return "🔄 replace" + case iac.ActionNoop: + return "⏭️ no-op" + case iac.ActionRead: + return "⏭️ read" + } + return string(a) +} + +// renderNarrative produces the prose paragraph between the header and +// the top-movers table. This is the function milestone 15 will replace +// with an LLM call: the contract is "input CostDiff, output 1-3 +// sentences in Markdown, no headings or lists". +// +// Today's templated logic walks the action stats and the delta sign to +// build a single sentence, optionally appended with an estimation- +// failure count when relevant. +func renderNarrative(d CostDiff) string { + if d.Stats.Priced == 0 { + return "No priceable resources in this plan." + } + + var verbs []string + if d.Stats.Created > 0 { + verbs = append(verbs, fmt.Sprintf("adds %d %s", d.Stats.Created, plural("resource", d.Stats.Created))) + } + if d.Stats.Deleted > 0 { + verbs = append(verbs, fmt.Sprintf("removes %d", d.Stats.Deleted)) + } + if d.Stats.Updated > 0 { + verbs = append(verbs, fmt.Sprintf("updates %d", d.Stats.Updated)) + } + if d.Stats.Replaced > 0 { + verbs = append(verbs, fmt.Sprintf("replaces %d", d.Stats.Replaced)) + } + actions := joinClauses(verbs) + + var direction string + switch { + case math.Abs(d.TotalMonthlyDelta) < centavoTolerance: + direction = "no net cost change" + case d.TotalMonthlyDelta > 0: + direction = "a net monthly cost increase of " + formatDelta(d.TotalMonthlyDelta) + default: + direction = "a net monthly cost decrease of " + formatDelta(d.TotalMonthlyDelta) + } + + sentence := fmt.Sprintf("This plan %s, with %s.", actions, direction) + + estFail := 0 + for _, s := range d.Skipped { + if strings.Contains(s.SkipReason, "estimation failed") { + estFail++ + } + } + if estFail > 0 { + sentence += fmt.Sprintf(" %d %s could not be priced.", estFail, plural("resource", estFail)) + } + + return sentence +} + +func plural(word string, n int) string { + if n == 1 { + return word + } + return word + "s" +} + +// joinClauses produces an Oxford-comma list. Empty input is an empty +// string; one element returns itself; two are joined by " and "; three +// or more by ", " with " and " before the last. +func joinClauses(parts []string) string { + switch len(parts) { + case 0: + return "" + case 1: + return parts[0] + case 2: + return parts[0] + " and " + parts[1] + } + return strings.Join(parts[:len(parts)-1], ", ") + ", and " + parts[len(parts)-1] +} + +// addressList formats a list of resource addresses inline as +// `addr1`, `addr2`, ... — used inside the per-resource caveat bullets. +func addressList(addrs []string) string { + var sb strings.Builder + for i, a := range addrs { + if i > 0 { + sb.WriteString(", ") + } + sb.WriteByte('`') + sb.WriteString(a) + sb.WriteByte('`') + } + return sb.String() +} + +var mdTemplate = template.Must(template.New("md").Funcs(template.FuncMap{ + "addressList": addressList, +}).Parse(markdownTemplate)) + +// markdownTemplate is the canonical layout for a CostDiff rendered as a +// PR comment. Whitespace is fiddly: blank lines are needed between +// Markdown sections (otherwise GitHub merges paragraphs), and trim +// markers on `{{- }}` actions are used to keep the template readable +// without injecting spurious indentation. If you change this, run +// `go test -update ./internal/diff/...` to refresh the goldens and +// eyeball the diff. +const markdownTemplate = "## 💰 Cloud Cost Impact\n" + + "\n" + + "**Net monthly change: {{.NetChange}}** {{.TrendEmoji}}\n" + + "\n" + + "{{.Narrative}}\n" + + "{{- if .HasTopMovers}}\n" + + "\n" + + "### Top movers by cost impact\n" + + "\n" + + "| Resource | Action | Δ Monthly | Confidence |\n" + + "|----------|--------|-----------|------------|\n" + + "{{- range .TopMovers}}\n" + + "| `{{.Address}}` | {{.Action}} | {{.Delta}} | {{.Confidence}} |\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .ShowFullBreakdown}}\n" + + "\n" + + "

\n" + + "📋 Full breakdown ({{.StatsPriced}} priced, {{.StatsSkippedTotal}} skipped)\n" + + "{{- if .HasCreated}}\n" + + "\n" + + "#### Created ({{len .Created}})\n" + + "{{- range .Created}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasDeleted}}\n" + + "\n" + + "#### Deleted ({{len .Deleted}})\n" + + "{{- range .Deleted}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasUpdated}}\n" + + "\n" + + "#### Updated ({{len .Updated}})\n" + + "{{- range .Updated}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasReplaced}}\n" + + "\n" + + "#### Replaced ({{len .Replaced}})\n" + + "{{- range .Replaced}}\n" + + "- `{{.Address}}` — {{.Delta}}\n" + + "{{- range .Breakdown}}\n" + + " - {{.Component}}: {{.Cost}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "{{- if .HasSkipped}}\n" + + "\n" + + "#### Skipped ({{len .Skipped}})\n" + + "{{- range .Skipped}}\n" + + "- `{{.Address}}` ({{.Type}}) — {{.SkipReason}}\n" + + "{{- end}}\n" + + "{{- end}}\n" + + "\n" + + "
\n" + + "{{- end}}\n" + + "{{- if .ShowCaveats}}\n" + + "\n" + + "
\n" + + "⚠️ Assumptions and caveats\n" + + "\n" + + "{{range .GlobalNotes}}- {{.}}\n{{end}}" + + "{{range .PerResourceNotes}}- {{.Note}} _(applies to: {{addressList .Addresses}})_\n{{end}}" + + "\n" + + "
\n" + + "{{- end}}\n" + + "\n" + + "---\n" + + "Generated by [CloudOracle]({{.RepoURL}}) · Confidence: **{{.AggregateConfidence}}**\n" + + "\n" diff --git a/internal/diff/markdown_test.go b/internal/diff/markdown_test.go new file mode 100644 index 0000000..ebe3374 --- /dev/null +++ b/internal/diff/markdown_test.go @@ -0,0 +1,514 @@ +package diff + +import ( + "flag" + "os" + "path/filepath" + "strings" + "testing" + + "CloudOracle/internal/iac" + "CloudOracle/internal/pricing" +) + +// updateGoldens regenerates the testdata/markdown_*.md fixtures from +// the current renderer output. Run with `go test -update +// ./internal/diff/...` after an intentional template change. Without +// the flag, tests compare against the existing files. +var updateGoldens = flag.Bool("update", false, "update golden Markdown files") + +// goldenCheck renders the diff with default config and compares against +// or rewrites the golden file. The comparison is byte-exact — Markdown +// whitespace matters because GitHub renders subtle differences (extra +// blank line collapses paragraphs into one). +func goldenCheck(t *testing.T, name string, d CostDiff) { + t.Helper() + got := RenderMarkdown(d) + path := filepath.Join("testdata", name) + if *updateGoldens { + if err := os.MkdirAll("testdata", 0o755); err != nil { + t.Fatalf("mkdir testdata: %v", err) + } + if err := os.WriteFile(path, []byte(got), 0o644); err != nil { + t.Fatalf("writing golden %q: %v", path, err) + } + return + } + wantBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading golden %q: %v (run with -update to create it)", path, err) + } + want := string(wantBytes) + if got != want { + t.Errorf("output differs from %s\n--- got ---\n%s\n--- want ---\n%s", + path, got, want) + } +} + +// ce builds a ChangeEstimate test fixture. Keeps the test cases short. +func ce(addr, typ string, action iac.Action, delta float64, conf pricing.Confidence) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: typ, + Action: action, + MonthlyDelta: delta, + Currency: "USD", + Confidence: conf, + } +} + +// withBreakdown wraps a ChangeEstimate adding line items so the full +// breakdown section has something interesting to render. +func withBreakdown(c pricing.ChangeEstimate, items ...pricing.LineItem) pricing.ChangeEstimate { + c.Breakdown = append([]pricing.LineItem(nil), items...) + return c +} + +func withNotes(c pricing.ChangeEstimate, notes ...string) pricing.ChangeEstimate { + c.Notes = append([]string(nil), notes...) + return c +} + +// happyPathDiff mimics the checkpoint-13.5 output: 6 resources, all +// creates, $389.35 total. This is the canonical PR-comment shape. +func happyPathDiff() CostDiff { + web := withNotes( + withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: 4.00}, + ), + "Operating system assumed Linux (plan does not specify)", + "Pricing assumes On-Demand (no Reserved Instances or Savings Plans)", + ) + web.AfterMonthly = 64.74 + + db := withNotes( + withBreakdown( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 71.36, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 59.86}, + pricing.LineItem{Component: "Storage", MonthlyUSD: 11.50}, + ), + "License: No license required (postgres/mysql/mariadb)", + ) + db.AfterMonthly = 71.36 + + disk := withNotes( + withBreakdown( + ce("aws_ebs_volume.disk", "aws_ebs_volume", iac.ActionCreate, 16.00, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: 16.00}, + ), + "IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s)", + ) + disk.AfterMonthly = 16.00 + + fn := withNotes( + ce("aws_lambda_function.fn", "aws_lambda_function", iac.ActionCreate, 0, pricing.ConfidenceLow), + "Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled", + ) + + nat := withNotes( + withBreakdown( + ce("aws_nat_gateway.nat", "aws_nat_gateway", iac.ActionCreate, 32.85, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Gateway", MonthlyUSD: 32.85}, + ), + "Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled", + ) + nat.AfterMonthly = 32.85 + + aurora := withNotes( + withBreakdown( + ce("aws_rds_cluster_instance.aurora", "aws_rds_cluster_instance", iac.ActionCreate, 204.40, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 204.40}, + ), + "Cluster-level storage and I/O charges not included (priced at aws_rds_cluster)", + "Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag", + "Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled", + ) + aurora.AfterMonthly = 204.40 + + all := []pricing.ChangeEstimate{aurora, db, web, nat, disk, fn} // sorted by abs delta desc + + return CostDiff{ + TotalMonthlyDelta: 389.35, + Currency: "USD", + Changes: all, + Created: all, + TopMovers: all[:5], + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{ + Total: 6, Created: 6, Priced: 6, + }, + } +} + +func TestRenderMarkdown_HappyPath(t *testing.T) { + goldenCheck(t, "markdown_happy_path.md", happyPathDiff()) +} + +func TestRenderMarkdown_NetDecrease(t *testing.T) { + web := withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionDelete, -64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: -60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: -4.00}, + ) + web.BeforeMonthly = 64.74 + disk := withBreakdown( + ce("aws_ebs_volume.disk", "aws_ebs_volume", iac.ActionDelete, -16.00, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: -16.00}, + ) + disk.BeforeMonthly = 16.00 + + all := []pricing.ChangeEstimate{web, disk} + d := CostDiff{ + TotalMonthlyDelta: -80.74, + Currency: "USD", + Changes: all, + Deleted: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost reduction this plan"}, + Stats: Stats{Total: 2, Deleted: 2, Priced: 2}, + } + goldenCheck(t, "markdown_net_decrease.md", d) +} + +func TestRenderMarkdown_NetZero(t *testing.T) { + // Replace where before == after price. Common when changing tags + // or other non-cost attributes. + a := withBreakdown( + ce("aws_instance.a", "aws_instance", iac.ActionReplace, 0, pricing.ConfidenceLow), + ) + a.BeforeMonthly = 50 + a.AfterMonthly = 50 + + all := []pricing.ChangeEstimate{a} + d := CostDiff{ + TotalMonthlyDelta: 0, + Currency: "USD", + Changes: all, + Replaced: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net zero cost change"}, + Stats: Stats{Total: 1, Replaced: 1, Priced: 1}, + } + goldenCheck(t, "markdown_net_zero.md", d) +} + +func TestRenderMarkdown_EmptyPlan(t *testing.T) { + d := CostDiff{ + Currency: "USD", + Confidence: pricing.ConfidenceHigh, + Notes: []string{"No priceable resources in plan"}, + Stats: Stats{}, + } + goldenCheck(t, "markdown_empty_plan.md", d) +} + +func TestRenderMarkdown_AllSkipped(t *testing.T) { + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: "aws_iam_role", + Action: iac.ActionCreate, + Currency: "USD", + Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + Confidence: pricing.ConfidenceHigh, + } + } + all := []pricing.ChangeEstimate{ + mk("aws_iam_role.r1"), mk("aws_iam_role.r2"), mk("aws_iam_role.r3"), + } + d := CostDiff{ + Currency: "USD", + Changes: all, + Skipped: all, + Confidence: pricing.ConfidenceHigh, + Notes: []string{ + "3 resources skipped (3 unsupported types, 0 estimation failures)", + "No priceable resources in plan", + }, + Stats: Stats{Total: 3, Skipped: 3}, + } + goldenCheck(t, "markdown_all_skipped.md", d) +} + +func TestRenderMarkdown_WithEstimationErrors(t *testing.T) { + web := withBreakdown( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 64.74, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 60.74}, + pricing.LineItem{Component: "RootEBS", MonthlyUSD: 4.00}, + ) + web.AfterMonthly = 64.74 + + broken := pricing.ChangeEstimate{ + ResourceAddress: "aws_instance.broken", + ResourceType: "aws_instance", + Action: iac.ActionCreate, + Currency: "USD", + Skipped: true, + SkipReason: "estimation failed: AccessDenied calling pricing API", + Confidence: pricing.ConfidenceHigh, + } + d := CostDiff{ + TotalMonthlyDelta: 64.74, + Currency: "USD", + Changes: []pricing.ChangeEstimate{web, broken}, + Created: []pricing.ChangeEstimate{web}, + Skipped: []pricing.ChangeEstimate{broken}, + TopMovers: []pricing.ChangeEstimate{web}, + Confidence: pricing.ConfidenceLow, + Notes: []string{ + "1 resources skipped (0 unsupported types, 1 estimation failures)", + "Net cost increase this plan", + }, + Stats: Stats{Total: 2, Created: 1, Skipped: 1, Priced: 1}, + } + goldenCheck(t, "markdown_with_estimation_errors.md", d) +} + +func TestRenderMarkdown_MixedActions(t *testing.T) { + cre := withBreakdown( + ce("aws_instance.new", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 100}, + ) + del := withBreakdown( + ce("aws_ebs_volume.old", "aws_ebs_volume", iac.ActionDelete, -16, pricing.ConfidenceMedium), + pricing.LineItem{Component: "Storage", MonthlyUSD: -16}, + ) + upd := withBreakdown( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionUpdate, 25, pricing.ConfidenceLow), + pricing.LineItem{Component: "Compute", MonthlyUSD: 25}, + pricing.LineItem{Component: "Storage", MonthlyUSD: 0}, + ) + rep := withBreakdown( + ce("aws_lambda_function.fn", "aws_lambda_function", iac.ActionReplace, 5, pricing.ConfidenceLow), + pricing.LineItem{Component: "ProvisionedConcurrency", MonthlyUSD: 5}, + ) + // Sorted by abs delta: 100, 25, -16, 5 + sorted := []pricing.ChangeEstimate{cre, upd, del, rep} + d := CostDiff{ + TotalMonthlyDelta: 114, + Currency: "USD", + Changes: sorted, + Created: []pricing.ChangeEstimate{cre}, + Deleted: []pricing.ChangeEstimate{del}, + Updated: []pricing.ChangeEstimate{upd}, + Replaced: []pricing.ChangeEstimate{rep}, + TopMovers: sorted, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{ + Total: 4, Created: 1, Deleted: 1, Updated: 1, Replaced: 1, Priced: 4, + }, + } + goldenCheck(t, "markdown_mixed_actions.md", d) +} + +func TestFormatDelta(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "$0.00"}, + {0.001, "$0.00"}, + {-0.001, "$0.00"}, + {0.004, "$0.00"}, + {0.005, "+$0.01"}, + {-0.005, "-$0.01"}, + {60.74, "+$60.74"}, + {-50.0, "-$50.00"}, + {1234.567, "+$1234.57"}, + } + for _, c := range cases { + if got := formatDelta(c.in); got != c.want { + t.Errorf("formatDelta(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestTrendEmoji(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "⚪"}, + {0.001, "⚪"}, + {-0.001, "⚪"}, + {0.005, "🔴"}, + {50, "🔴"}, + {-50, "🟢"}, + } + for _, c := range cases { + if got := trendEmoji(c.in); got != c.want { + t.Errorf("trendEmoji(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestActionDisplay(t *testing.T) { + cases := map[iac.Action]string{ + iac.ActionCreate: "🆕 create", + iac.ActionDelete: "❌ delete", + iac.ActionUpdate: "♻️ update", + iac.ActionReplace: "🔄 replace", + iac.ActionNoop: "⏭️ no-op", + iac.ActionRead: "⏭️ read", + } + for a, want := range cases { + if got := actionDisplay(a); got != want { + t.Errorf("actionDisplay(%q) = %q, want %q", a, got, want) + } + } + // Unknown action passes through. + if got := actionDisplay(iac.Action("import")); got != "import" { + t.Errorf("actionDisplay(import) = %q, want literal pass-through", got) + } +} + +func TestMarkdownConfig_Defaults(t *testing.T) { + d := happyPathDiff() + a := RenderMarkdown(d) + b := RenderMarkdownWithConfig(d, MarkdownConfig{}) + if a != b { + t.Errorf("zero MarkdownConfig should produce same output as RenderMarkdown") + } +} + +func TestMarkdownConfig_CustomMarker(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{CommentMarker: "my-custom-marker"}) + if !strings.Contains(out, "") { + t.Errorf("custom marker not in output") + } + if strings.Contains(out, "") { + t.Errorf("default marker leaked into output") + } +} + +func TestMarkdownConfig_HideBreakdown(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{HideFullBreakdown: true}) + if strings.Contains(out, "Full breakdown") { + t.Errorf("HideFullBreakdown=true did not omit the section") + } + // Caveats still present (only Breakdown was hidden). + if !strings.Contains(out, "Assumptions and caveats") { + t.Errorf("Caveats section unexpectedly missing") + } +} + +func TestMarkdownConfig_HideCaveats(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{HideCaveats: true}) + if strings.Contains(out, "Assumptions and caveats") { + t.Errorf("HideCaveats=true did not omit the section") + } + if !strings.Contains(out, "Full breakdown") { + t.Errorf("Breakdown section unexpectedly missing") + } +} + +func TestMarkdownConfig_TopMoversCountClampsDown(t *testing.T) { + d := happyPathDiff() + out := RenderMarkdownWithConfig(d, MarkdownConfig{TopMoversCount: 2}) + // Count rows in the top-movers table by counting the table-row prefix. + rows := strings.Count(out, "\n| `aws_") + if rows != 2 { + t.Errorf("got %d top-movers rows, want 2", rows) + } +} + +func TestRenderNarrative_AllShapes(t *testing.T) { + cases := []struct { + name string + d CostDiff + want string + }{ + { + name: "no priceable", + d: CostDiff{Stats: Stats{Total: 3, Skipped: 3}}, + want: "No priceable resources in this plan.", + }, + { + name: "increase one create", + d: CostDiff{ + TotalMonthlyDelta: 50, + Stats: Stats{Created: 1, Priced: 1}, + }, + want: "This plan adds 1 resource, with a net monthly cost increase of +$50.00.", + }, + { + name: "decrease one delete", + d: CostDiff{ + TotalMonthlyDelta: -50, + Stats: Stats{Deleted: 1, Priced: 1}, + }, + want: "This plan removes 1, with a net monthly cost decrease of -$50.00.", + }, + { + name: "zero with replace", + d: CostDiff{ + TotalMonthlyDelta: 0, + Stats: Stats{Replaced: 1, Priced: 1}, + }, + want: "This plan replaces 1, with no net cost change.", + }, + { + name: "with estimation failures", + d: CostDiff{ + TotalMonthlyDelta: 50, + Stats: Stats{Created: 1, Priced: 1, Skipped: 1}, + Skipped: []pricing.ChangeEstimate{ + {SkipReason: "estimation failed: timeout"}, + }, + }, + want: "This plan adds 1 resource, with a net monthly cost increase of +$50.00. 1 resource could not be priced.", + }, + { + name: "mixed verbs all four", + d: CostDiff{ + TotalMonthlyDelta: 100, + Stats: Stats{Created: 2, Deleted: 1, Updated: 3, Replaced: 1, Priced: 7}, + }, + want: "This plan adds 2 resources, removes 1, updates 3, and replaces 1, with a net monthly cost increase of +$100.00.", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := renderNarrative(c.d); got != c.want { + t.Errorf("got:\n %q\nwant:\n %q", got, c.want) + } + }) + } +} + +func TestDeduplicateNotes(t *testing.T) { + c1 := withNotes(ce("aws_instance.a", "aws_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "Operating system assumed Linux", "Common note") + c2 := withNotes(ce("aws_instance.b", "aws_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "Operating system assumed Linux") + c3 := withNotes(ce("aws_instance.c", "aws_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "Operating system assumed Linux", "Unique to c") + + got := deduplicateNotes([]pricing.ChangeEstimate{c1, c2, c3}) + + if len(got) != 3 { + t.Fatalf("got %d unique notes, want 3", len(got)) + } + if got[0].Note != "Operating system assumed Linux" { + t.Errorf("got[0].Note = %q", got[0].Note) + } + if len(got[0].Addresses) != 3 { + t.Errorf("Linux note addresses = %v, want 3 addresses", got[0].Addresses) + } + if got[1].Note != "Common note" || len(got[1].Addresses) != 1 { + t.Errorf("got[1] = %+v", got[1]) + } + if got[2].Note != "Unique to c" || got[2].Addresses[0] != "aws_instance.c" { + t.Errorf("got[2] = %+v", got[2]) + } +} diff --git a/internal/diff/testdata/markdown_all_skipped.md b/internal/diff/testdata/markdown_all_skipped.md new file mode 100644 index 0000000..0ca0626 --- /dev/null +++ b/internal/diff/testdata/markdown_all_skipped.md @@ -0,0 +1,27 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +No priceable resources in this plan. + +
+📋 Full breakdown (0 priced, 3 skipped) + +#### Skipped (3) +- `aws_iam_role.r1` (aws_iam_role) — unsupported resource type: aws_iam_role +- `aws_iam_role.r2` (aws_iam_role) — unsupported resource type: aws_iam_role +- `aws_iam_role.r3` (aws_iam_role) — unsupported resource type: aws_iam_role + +
+ +
+⚠️ Assumptions and caveats + +- 3 resources skipped (3 unsupported types, 0 estimation failures) +- No priceable resources in plan + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **high** + diff --git a/internal/diff/testdata/markdown_empty_plan.md b/internal/diff/testdata/markdown_empty_plan.md new file mode 100644 index 0000000..1d6834f --- /dev/null +++ b/internal/diff/testdata/markdown_empty_plan.md @@ -0,0 +1,21 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +No priceable resources in this plan. + +
+📋 Full breakdown (0 priced, 0 skipped) + +
+ +
+⚠️ Assumptions and caveats + +- No priceable resources in plan + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **high** + diff --git a/internal/diff/testdata/markdown_happy_path.md b/internal/diff/testdata/markdown_happy_path.md new file mode 100644 index 0000000..1fdcbbf --- /dev/null +++ b/internal/diff/testdata/markdown_happy_path.md @@ -0,0 +1,55 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$389.35** 🔴 + +This plan adds 6 resources, with a net monthly cost increase of +$389.35. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_rds_cluster_instance.aurora` | 🆕 create | +$204.40 | low | +| `aws_db_instance.db` | 🆕 create | +$71.36 | low | +| `aws_instance.web` | 🆕 create | +$64.74 | low | +| `aws_nat_gateway.nat` | 🆕 create | +$32.85 | medium | +| `aws_ebs_volume.disk` | 🆕 create | +$16.00 | medium | + +
+📋 Full breakdown (6 priced, 0 skipped) + +#### Created (6) +- `aws_rds_cluster_instance.aurora` — +$204.40 + - Compute: +$204.40 +- `aws_db_instance.db` — +$71.36 + - Compute: +$59.86 + - Storage: +$11.50 +- `aws_instance.web` — +$64.74 + - Compute: +$60.74 + - RootEBS: +$4.00 +- `aws_nat_gateway.nat` — +$32.85 + - Gateway: +$32.85 +- `aws_ebs_volume.disk` — +$16.00 + - Storage: +$16.00 +- `aws_lambda_function.fn` — $0.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost increase this plan +- Cluster-level storage and I/O charges not included (priced at aws_rds_cluster) _(applies to: `aws_rds_cluster_instance.aurora`)_ +- Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag _(applies to: `aws_rds_cluster_instance.aurora`)_ +- Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled _(applies to: `aws_rds_cluster_instance.aurora`)_ +- License: No license required (postgres/mysql/mariadb) _(applies to: `aws_db_instance.db`)_ +- Operating system assumed Linux (plan does not specify) _(applies to: `aws_instance.web`)_ +- Pricing assumes On-Demand (no Reserved Instances or Savings Plans) _(applies to: `aws_instance.web`)_ +- Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled _(applies to: `aws_nat_gateway.nat`)_ +- IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s) _(applies to: `aws_ebs_volume.disk`)_ +- Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled _(applies to: `aws_lambda_function.fn`)_ + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_mixed_actions.md b/internal/diff/testdata/markdown_mixed_actions.md new file mode 100644 index 0000000..63ef614 --- /dev/null +++ b/internal/diff/testdata/markdown_mixed_actions.md @@ -0,0 +1,47 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$114.00** 🔴 + +This plan adds 1 resource, removes 1, updates 1, and replaces 1, with a net monthly cost increase of +$114.00. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.new` | 🆕 create | +$100.00 | low | +| `aws_db_instance.db` | ♻️ update | +$25.00 | low | +| `aws_ebs_volume.old` | ❌ delete | -$16.00 | medium | +| `aws_lambda_function.fn` | 🔄 replace | +$5.00 | low | + +
+📋 Full breakdown (4 priced, 0 skipped) + +#### Created (1) +- `aws_instance.new` — +$100.00 + - Compute: +$100.00 + +#### Deleted (1) +- `aws_ebs_volume.old` — -$16.00 + - Storage: -$16.00 + +#### Updated (1) +- `aws_db_instance.db` — +$25.00 + - Compute: +$25.00 + - Storage: $0.00 + +#### Replaced (1) +- `aws_lambda_function.fn` — +$5.00 + - ProvisionedConcurrency: +$5.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost increase this plan + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_net_decrease.md b/internal/diff/testdata/markdown_net_decrease.md new file mode 100644 index 0000000..1bcff55 --- /dev/null +++ b/internal/diff/testdata/markdown_net_decrease.md @@ -0,0 +1,35 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: -$80.74** 🟢 + +This plan removes 2, with a net monthly cost decrease of -$80.74. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.web` | ❌ delete | -$64.74 | low | +| `aws_ebs_volume.disk` | ❌ delete | -$16.00 | medium | + +
+📋 Full breakdown (2 priced, 0 skipped) + +#### Deleted (2) +- `aws_instance.web` — -$64.74 + - Compute: -$60.74 + - RootEBS: -$4.00 +- `aws_ebs_volume.disk` — -$16.00 + - Storage: -$16.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net cost reduction this plan + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_net_zero.md b/internal/diff/testdata/markdown_net_zero.md new file mode 100644 index 0000000..9300176 --- /dev/null +++ b/internal/diff/testdata/markdown_net_zero.md @@ -0,0 +1,30 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: $0.00** ⚪ + +This plan replaces 1, with no net cost change. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.a` | 🔄 replace | $0.00 | low | + +
+📋 Full breakdown (1 priced, 0 skipped) + +#### Replaced (1) +- `aws_instance.a` — $0.00 + +
+ +
+⚠️ Assumptions and caveats + +- Net zero cost change + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_with_estimation_errors.md b/internal/diff/testdata/markdown_with_estimation_errors.md new file mode 100644 index 0000000..61df858 --- /dev/null +++ b/internal/diff/testdata/markdown_with_estimation_errors.md @@ -0,0 +1,36 @@ +## 💰 Cloud Cost Impact + +**Net monthly change: +$64.74** 🔴 + +This plan adds 1 resource, with a net monthly cost increase of +$64.74. 1 resource could not be priced. + +### Top movers by cost impact + +| Resource | Action | Δ Monthly | Confidence | +|----------|--------|-----------|------------| +| `aws_instance.web` | 🆕 create | +$64.74 | low | + +
+📋 Full breakdown (1 priced, 1 skipped) + +#### Created (1) +- `aws_instance.web` — +$64.74 + - Compute: +$60.74 + - RootEBS: +$4.00 + +#### Skipped (1) +- `aws_instance.broken` (aws_instance) — estimation failed: AccessDenied calling pricing API + +
+ +
+⚠️ Assumptions and caveats + +- 1 resources skipped (0 unsupported types, 1 estimation failures) +- Net cost increase this plan + +
+ +--- +Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/types.go b/internal/diff/types.go new file mode 100644 index 0000000..39774d6 --- /dev/null +++ b/internal/diff/types.go @@ -0,0 +1,92 @@ +// Package diff aggregates per-resource cost estimates from internal/pricing +// into a plan-wide picture. The output (CostDiff) is consumed by the Markdown +// renderer in milestone 14.2 and the LLM narrative layer in milestone 15. +// +// This package does NOT call the AWS Pricing API directly — it delegates to +// pricing.EstimateChange via an injected estimator. The split lets analysis +// logic (sorting, categorising, top-movers, notes) be tested without a Source +// or fixture JSON. +package diff + +import ( + "context" + + "CloudOracle/internal/pricing" +) + +// CostDiff is the aggregate cost impact of a single Terraform plan. +// +// Changes contains one entry per resource_change in the plan, including +// changes that could not be priced (those carry Skipped=true). Changes is +// sorted by absolute MonthlyDelta descending, so the most impactful items +// surface first; Skipped items have delta=0 and naturally sort last. +// +// Created, Deleted, Updated, Replaced, and Skipped are non-overlapping +// subsets of Changes (with one nuance: items with Skipped=true never +// appear in the action-keyed slices regardless of their Action — even an +// unsupported aws_iam_role with action=create lands in Skipped only). +// All five subsets share the same descending-by-abs-delta order as Changes. +// +// TopMovers is the leading slice of Changes (length min(TopMoversCount, +// non-skipped count)) used by the renderer for the "biggest changes" +// summary at the top of a PR comment. Skipped items are excluded from +// TopMovers because their delta of 0 carries no signal. +// +// Confidence is the weakest non-skipped confidence across Changes (low +// dominates medium dominates high). With no non-skipped changes, the +// default is ConfidenceHigh — there is nothing to be unsure about. +// +// Notes are plan-wide observations generated by Analyze itself, not +// inherited from individual ChangeEstimates: skip-count summaries, net +// direction of the plan, "no priceable resources" diagnostics, etc. +type CostDiff struct { + TotalMonthlyDelta float64 + Currency string + + Changes []pricing.ChangeEstimate + + Created []pricing.ChangeEstimate + Deleted []pricing.ChangeEstimate + Updated []pricing.ChangeEstimate + Replaced []pricing.ChangeEstimate + Skipped []pricing.ChangeEstimate + + TopMovers []pricing.ChangeEstimate + + Confidence pricing.Confidence + + Notes []string + + Stats Stats +} + +// Stats holds counts by category for quick summary rendering. The values +// form a disjoint partition of the plan: every change is counted in +// exactly one of {Created, Deleted, Updated, Replaced, NoOp, Skipped}, +// so Total = Created + Deleted + Updated + Replaced + NoOp + Skipped. +// +// NoOp counts no-op AND read actions (both have zero cost impact and no +// real "change" to price). Skipped counts the items that ended up in the +// Skipped slice MINUS those NoOp items — i.e. only the changes we +// genuinely could not price (unsupported types, data sources, +// estimation failures). Priced is a convenience alias for Created + +// Deleted + Updated + Replaced. +type Stats struct { + Total int + Created int + Deleted int + Updated int + Replaced int + NoOp int + Skipped int + Priced int +} + +// Source is anything that can serve raw Pricing API products. Both +// *pricing.Client (live calls) and *pricing.Cache (disk-cached) satisfy +// it. Defining the interface here — rather than importing pricing's +// unexported productGetter — keeps the diff package decoupled from the +// pricing package's internal types. +type Source interface { + GetProducts(ctx context.Context, serviceCode string, filters map[string]string) ([]string, error) +} diff --git a/internal/diff/types_test.go b/internal/diff/types_test.go new file mode 100644 index 0000000..98ee756 --- /dev/null +++ b/internal/diff/types_test.go @@ -0,0 +1,28 @@ +package diff + +import ( + "testing" + + "CloudOracle/internal/pricing" +) + +func TestWeakestConfidence(t *testing.T) { + cases := []struct { + a, b, want pricing.Confidence + }{ + {pricing.ConfidenceHigh, pricing.ConfidenceHigh, pricing.ConfidenceHigh}, + {pricing.ConfidenceHigh, pricing.ConfidenceMedium, pricing.ConfidenceMedium}, + {pricing.ConfidenceMedium, pricing.ConfidenceHigh, pricing.ConfidenceMedium}, + {pricing.ConfidenceHigh, pricing.ConfidenceLow, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceHigh, pricing.ConfidenceLow}, + {pricing.ConfidenceMedium, pricing.ConfidenceMedium, pricing.ConfidenceMedium}, + {pricing.ConfidenceMedium, pricing.ConfidenceLow, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceMedium, pricing.ConfidenceLow}, + {pricing.ConfidenceLow, pricing.ConfidenceLow, pricing.ConfidenceLow}, + } + for _, c := range cases { + if got := weakestConfidence(c.a, c.b); got != c.want { + t.Errorf("weakestConfidence(%q, %q) = %q, want %q", c.a, c.b, got, c.want) + } + } +} From 22666daada29d1eab947ff858cb9f4f2235649a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 18:55:58 -0400 Subject: [PATCH 11/21] feat: ensure Markdown blocks are separated by blank lines for proper rendering --- internal/diff/markdown.go | 12 +++--- internal/diff/markdown_test.go | 39 +++++++++++++++++++ .../diff/testdata/markdown_all_skipped.md | 3 ++ internal/diff/testdata/markdown_empty_plan.md | 2 + internal/diff/testdata/markdown_happy_path.md | 3 ++ .../diff/testdata/markdown_mixed_actions.md | 6 +++ .../diff/testdata/markdown_net_decrease.md | 3 ++ internal/diff/testdata/markdown_net_zero.md | 3 ++ .../markdown_with_estimation_errors.md | 4 ++ 9 files changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/diff/markdown.go b/internal/diff/markdown.go index cfa6089..bb803f4 100644 --- a/internal/diff/markdown.go +++ b/internal/diff/markdown.go @@ -432,7 +432,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- if .HasCreated}}\n" + "\n" + "#### Created ({{len .Created}})\n" + - "{{- range .Created}}\n" + + "{{range .Created}}\n" + "- `{{.Address}}` — {{.Delta}}\n" + "{{- range .Breakdown}}\n" + " - {{.Component}}: {{.Cost}}\n" + @@ -442,7 +442,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- if .HasDeleted}}\n" + "\n" + "#### Deleted ({{len .Deleted}})\n" + - "{{- range .Deleted}}\n" + + "{{range .Deleted}}\n" + "- `{{.Address}}` — {{.Delta}}\n" + "{{- range .Breakdown}}\n" + " - {{.Component}}: {{.Cost}}\n" + @@ -452,7 +452,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- if .HasUpdated}}\n" + "\n" + "#### Updated ({{len .Updated}})\n" + - "{{- range .Updated}}\n" + + "{{range .Updated}}\n" + "- `{{.Address}}` — {{.Delta}}\n" + "{{- range .Breakdown}}\n" + " - {{.Component}}: {{.Cost}}\n" + @@ -462,7 +462,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- if .HasReplaced}}\n" + "\n" + "#### Replaced ({{len .Replaced}})\n" + - "{{- range .Replaced}}\n" + + "{{range .Replaced}}\n" + "- `{{.Address}}` — {{.Delta}}\n" + "{{- range .Breakdown}}\n" + " - {{.Component}}: {{.Cost}}\n" + @@ -472,7 +472,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- if .HasSkipped}}\n" + "\n" + "#### Skipped ({{len .Skipped}})\n" + - "{{- range .Skipped}}\n" + + "{{range .Skipped}}\n" + "- `{{.Address}}` ({{.Type}}) — {{.SkipReason}}\n" + "{{- end}}\n" + "{{- end}}\n" + @@ -491,5 +491,7 @@ const markdownTemplate = "## 💰 Cloud Cost Impact\n" + "{{- end}}\n" + "\n" + "---\n" + + "\n" + "Generated by [CloudOracle]({{.RepoURL}}) · Confidence: **{{.AggregateConfidence}}**\n" + + "\n" + "\n" diff --git a/internal/diff/markdown_test.go b/internal/diff/markdown_test.go index ebe3374..5a926c4 100644 --- a/internal/diff/markdown_test.go +++ b/internal/diff/markdown_test.go @@ -486,6 +486,45 @@ func TestRenderNarrative_AllShapes(t *testing.T) { } } +// TestRenderMarkdown_BlocksAreSeparatedByBlankLines guards against a +// past regression where text/template's `{{- range}}` trim swallowed +// the newline after section headings, causing GitHub Markdown to +// coalesce blocks (the table absorbed the preceding header,
+// rendered as literal HTML, etc). Each marker below is the start of a +// distinct Markdown block; in a well-formed comment, each one must be +// preceded by a blank line ("\n\n") so GitHub renders them separately. +func TestRenderMarkdown_BlocksAreSeparatedByBlankLines(t *testing.T) { + out := RenderMarkdown(happyPathDiff()) + + // "---\n" (rule line ending) avoids colliding with the table-separator + // row "|----------|...|" which embeds "---" but never "---\n". + markers := []string{ + "**Net monthly change", + "### Top movers", + "| Resource | Action", + "
", + "---\n", + "", + " diff --git a/internal/diff/testdata/markdown_empty_plan.md b/internal/diff/testdata/markdown_empty_plan.md index 1d6834f..7c4f5f1 100644 --- a/internal/diff/testdata/markdown_empty_plan.md +++ b/internal/diff/testdata/markdown_empty_plan.md @@ -17,5 +17,7 @@ No priceable resources in this plan.
--- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **high** + diff --git a/internal/diff/testdata/markdown_happy_path.md b/internal/diff/testdata/markdown_happy_path.md index 1fdcbbf..8240fa3 100644 --- a/internal/diff/testdata/markdown_happy_path.md +++ b/internal/diff/testdata/markdown_happy_path.md @@ -18,6 +18,7 @@ This plan adds 6 resources, with a net monthly cost increase of +$389.35. 📋 Full breakdown (6 priced, 0 skipped) #### Created (6) + - `aws_rds_cluster_instance.aurora` — +$204.40 - Compute: +$204.40 - `aws_db_instance.db` — +$71.36 @@ -51,5 +52,7 @@ This plan adds 6 resources, with a net monthly cost increase of +$389.35.
--- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_mixed_actions.md b/internal/diff/testdata/markdown_mixed_actions.md index 63ef614..2cc150d 100644 --- a/internal/diff/testdata/markdown_mixed_actions.md +++ b/internal/diff/testdata/markdown_mixed_actions.md @@ -17,19 +17,23 @@ This plan adds 1 resource, removes 1, updates 1, and replaces 1, with a net mont 📋 Full breakdown (4 priced, 0 skipped) #### Created (1) + - `aws_instance.new` — +$100.00 - Compute: +$100.00 #### Deleted (1) + - `aws_ebs_volume.old` — -$16.00 - Storage: -$16.00 #### Updated (1) + - `aws_db_instance.db` — +$25.00 - Compute: +$25.00 - Storage: $0.00 #### Replaced (1) + - `aws_lambda_function.fn` — +$5.00 - ProvisionedConcurrency: +$5.00 @@ -43,5 +47,7 @@ This plan adds 1 resource, removes 1, updates 1, and replaces 1, with a net mont --- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_net_decrease.md b/internal/diff/testdata/markdown_net_decrease.md index 1bcff55..df9659e 100644 --- a/internal/diff/testdata/markdown_net_decrease.md +++ b/internal/diff/testdata/markdown_net_decrease.md @@ -15,6 +15,7 @@ This plan removes 2, with a net monthly cost decrease of -$80.74. 📋 Full breakdown (2 priced, 0 skipped) #### Deleted (2) + - `aws_instance.web` — -$64.74 - Compute: -$60.74 - RootEBS: -$4.00 @@ -31,5 +32,7 @@ This plan removes 2, with a net monthly cost decrease of -$80.74. --- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_net_zero.md b/internal/diff/testdata/markdown_net_zero.md index 9300176..8badefc 100644 --- a/internal/diff/testdata/markdown_net_zero.md +++ b/internal/diff/testdata/markdown_net_zero.md @@ -14,6 +14,7 @@ This plan replaces 1, with no net cost change. 📋 Full breakdown (1 priced, 0 skipped) #### Replaced (1) + - `aws_instance.a` — $0.00 @@ -26,5 +27,7 @@ This plan replaces 1, with no net cost change. --- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + diff --git a/internal/diff/testdata/markdown_with_estimation_errors.md b/internal/diff/testdata/markdown_with_estimation_errors.md index 61df858..17d5458 100644 --- a/internal/diff/testdata/markdown_with_estimation_errors.md +++ b/internal/diff/testdata/markdown_with_estimation_errors.md @@ -14,11 +14,13 @@ This plan adds 1 resource, with a net monthly cost increase of +$64.74. 1 resour 📋 Full breakdown (1 priced, 1 skipped) #### Created (1) + - `aws_instance.web` — +$64.74 - Compute: +$60.74 - RootEBS: +$4.00 #### Skipped (1) + - `aws_instance.broken` (aws_instance) — estimation failed: AccessDenied calling pricing API @@ -32,5 +34,7 @@ This plan adds 1 resource, with a net monthly cost increase of +$64.74. 1 resour --- + Generated by [CloudOracle](https://github.com/Cro22/CloudOracle) · Confidence: **low** + From bf10feef5ab2bb0056c5a3b754eaf8fa9b2ee6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 22:22:51 -0400 Subject: [PATCH 12/21] feat: refactor LLM narrative generation to separate summary and text generation methods --- internal/diff/narrative.go | 317 ++++++++++++ internal/diff/narrative_integration_test.go | 158 ++++++ internal/diff/narrative_test.go | 478 ++++++++++++++++++ .../testdata/narrative_prompts/happy_path.txt | 36 ++ internal/llm/claude.go | 4 +- internal/llm/gemini.go | 5 +- internal/llm/openai.go | 5 +- internal/llm/provider.go | 10 + 8 files changed, 1008 insertions(+), 5 deletions(-) create mode 100644 internal/diff/narrative.go create mode 100644 internal/diff/narrative_integration_test.go create mode 100644 internal/diff/narrative_test.go create mode 100644 internal/diff/testdata/narrative_prompts/happy_path.txt diff --git a/internal/diff/narrative.go b/internal/diff/narrative.go new file mode 100644 index 0000000..65a10f8 --- /dev/null +++ b/internal/diff/narrative.go @@ -0,0 +1,317 @@ +package diff + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "math" + "strings" + + "CloudOracle/internal/llm" +) + +// maxNarrativeChars caps the LLM response length at roughly three long +// sentences. The spec asks for 1-3 inline sentences; anything substantially +// longer is the LLM ignoring the instruction (or hallucinating bullets) and +// is rejected in favor of the templated fallback. +const maxNarrativeChars = 500 + +// promptTopMoversN is the number of top movers included in the prompt. +// More than three crowds the model with low-signal items; fewer hides +// context the model needs to identify the primary driver. +const promptTopMoversN = 3 + +// promptCaveatsN caps the assumption list inside the prompt to avoid +// pushing the model into the weeds. Eight is enough to surface the +// notable Linux/Aurora/license assumptions we have today without +// drowning the actual cost data. +const promptCaveatsN = 8 + +// preamblePrefixes are common conversational openers some models tack on +// despite "no preamble" instructions. Stripped case-insensitively. Order +// matters: longer, more specific prefixes are listed first so they match +// before their shorter substrings. +var preamblePrefixes = []string{ + "Here is the narrative:", + "Here's the narrative:", + "Here is the narrative", + "Here's the narrative", + "Here is the summary:", + "Here's the summary:", + "Here is:", + "Here's:", + "Here is", + "Here's", + "Certainly,", + "Certainly.", + "Certainly!", + "Of course,", + "Of course.", + "Of course!", + "Sure,", + "Sure.", + "Sure!", +} + +// RenderMarkdownWithLLM is RenderMarkdown but uses an LLM provider to +// generate the narrative paragraph instead of the templated default. +// +// On any LLM error (network, rate limit, parse error, empty/whitespace +// response, oversized response, context cancellation), it falls back +// silently to the templated narrative produced by RenderMarkdown — the +// fallback ensures CloudOracle always emits a valid PR comment, even +// when the LLM is unavailable. Failures are logged at slog.Warn for +// debugging; the comment never carries an "LLM failed" notice. +// +// If provider is nil, behavior is identical to RenderMarkdown — no LLM +// call is attempted and no warning is logged. +// +// Sanity checks applied to the LLM output before it replaces the +// template: +// +// - empty / whitespace-only responses are treated as failure +// - responses longer than 500 characters are treated as failure +// (the LLM ignored the "1-3 sentences" instruction) +// - common preambles ("Here is the narrative:", "Sure,", ...) are +// stripped from the front +// - leading/trailing whitespace is trimmed +// - paragraph breaks pass but emit a warn (we expect inline prose) +func RenderMarkdownWithLLM(ctx context.Context, d CostDiff, provider llm.Provider) string { + return RenderMarkdownWithLLMConfig(ctx, d, provider, MarkdownConfig{}) +} + +// RenderMarkdownWithLLMConfig is RenderMarkdownWithLLM with explicit +// configuration. See MarkdownConfig and RenderMarkdownWithLLM. +func RenderMarkdownWithLLMConfig(ctx context.Context, d CostDiff, provider llm.Provider, cfg MarkdownConfig) string { + cfg = applyDefaults(cfg) + data := buildTemplateData(d, cfg) + if narrative, ok := generateLLMNarrative(ctx, d, provider); ok { + data.Narrative = narrative + } + var buf bytes.Buffer + if err := mdTemplate.Execute(&buf, data); err != nil { + return fmt.Sprintf("CloudOracle render error: %v", err) + } + return buf.String() +} + +// BuildPRNarrativePrompt constructs the prompt sent to the LLM provider +// for the PR narrative. Exposed for testability (so unit tests can +// verify prompt shape without making real API calls) and for callers +// that wire CloudOracle to an LLM not covered by internal/llm. +// +// The output is plain text — not Markdown — so it renders sensibly in +// any chat-completion or text-completion API. +func BuildPRNarrativePrompt(d CostDiff) string { + var sb strings.Builder + sb.WriteString("You are reviewing a Terraform pull request as a senior cloud engineer. ") + sb.WriteString("Your output is a 1-3 sentence narrative that will appear at the top of a PR comment, ") + sb.WriteString("above a table that already shows the per-resource cost breakdown.\n\n") + + sb.WriteString("# Cost change summary\n") + switch { + case d.Stats.Total == 0: + sb.WriteString("No priceable changes in this plan (empty plan).\n\n") + case d.Stats.Priced == 0: + sb.WriteString(fmt.Sprintf("No priced resources in this plan (%d skipped).\n\n", d.Stats.Skipped)) + default: + sb.WriteString("Total monthly delta: ") + sb.WriteString(formatDelta(d.TotalMonthlyDelta)) + sb.WriteString("\nDirection: ") + sb.WriteString(directionWord(d.TotalMonthlyDelta)) + sb.WriteString("\nStats: ") + sb.WriteString(statsSummary(d.Stats)) + sb.WriteString("\n\n") + } + + sb.WriteString("# Top resources by impact\n") + if len(d.TopMovers) == 0 { + sb.WriteString("(none)\n\n") + } else { + n := min(promptTopMoversN, len(d.TopMovers)) + for i := range n { + m := d.TopMovers[i] + sb.WriteString(fmt.Sprintf("- %s (%s, action=%s): %s per month", + m.ResourceAddress, m.ResourceType, m.Action, formatDelta(m.MonthlyDelta))) + if len(m.Breakdown) > 0 { + sb.WriteString(" — components: ") + for j, li := range m.Breakdown { + if j > 0 { + sb.WriteString(", ") + } + sb.WriteString(li.Component) + sb.WriteByte(' ') + sb.WriteString(formatDelta(li.MonthlyUSD)) + } + } + sb.WriteByte('\n') + } + sb.WriteByte('\n') + } + + sb.WriteString("# Notable assumptions in this estimate\n") + caveats := collectCaveats(d, promptCaveatsN) + if len(caveats) == 0 { + sb.WriteString("(none)\n\n") + } else { + for _, c := range caveats { + sb.WriteString("- ") + sb.WriteString(c) + sb.WriteByte('\n') + } + sb.WriteByte('\n') + } + + sb.WriteString("# Your task\n") + sb.WriteString("Write 1-3 sentences that:\n") + sb.WriteString("1. Identify the PRIMARY DRIVER of cost change (do not summarize the table; pick the dominant resource and explain its weight).\n") + sb.WriteString("2. If a clear lower-cost alternative exists for the primary driver (smaller instance class, different storage type, etc.), mention it as an \"if X, consider Y\" — never as a prescription.\n") + sb.WriteString("3. Optionally note one risk if applicable (e.g., uncovered cost like data processing, license assumption that may not hold).\n\n") + sb.WriteString("DO NOT:\n") + sb.WriteString("- Repeat the total monthly delta (it's already in the bold above your output).\n") + sb.WriteString("- List resources by name unless they are the primary driver.\n") + sb.WriteString("- Use cheerleading language (\"great\", \"looks good\", \"concerning\").\n") + sb.WriteString("- Use markdown headings or lists (your output is inline prose only).\n") + sb.WriteString("- Suggest IaC changes (\"you should add...\"); only point out cost properties.\n\n") + sb.WriteString("Output only the prose. No preamble. No \"Here is the narrative:\". Just the 1-3 sentences.") + return sb.String() +} + +// generateLLMNarrative attempts to produce a narrative via the LLM +// provider. On any failure it returns ok=false so the caller falls back +// to the templated narrative. Failures are logged at slog.Warn. +func generateLLMNarrative(ctx context.Context, d CostDiff, provider llm.Provider) (string, bool) { + if provider == nil { + return "", false + } + if err := ctx.Err(); err != nil { + slog.Warn("PR narrative: context already cancelled before LLM call", "err", err) + return "", false + } + prompt := BuildPRNarrativePrompt(d) + raw, err := provider.GenerateText(ctx, prompt) + if err != nil { + slog.Warn("PR narrative: LLM provider returned error", + "provider", provider.Name(), "err", err) + return "", false + } + if err := ctx.Err(); err != nil { + slog.Warn("PR narrative: context cancelled during LLM call", + "provider", provider.Name(), "err", err) + return "", false + } + cleaned := cleanNarrative(raw) + if cleaned == "" { + slog.Warn("PR narrative: LLM returned empty/whitespace response", + "provider", provider.Name(), "raw_len", len(raw)) + return "", false + } + if len(cleaned) > maxNarrativeChars { + slog.Warn("PR narrative: LLM response exceeded max length, falling back", + "provider", provider.Name(), "len", len(cleaned), "max", maxNarrativeChars) + return "", false + } + if strings.Contains(cleaned, "\n\n") { + slog.Warn("PR narrative: LLM response contains paragraph breaks; expected inline prose", + "provider", provider.Name()) + } + return cleaned, true +} + +// cleanNarrative trims surrounding whitespace and strips common +// conversational preambles such as "Here is the narrative:" that some +// models emit despite explicit "no preamble" instructions. +func cleanNarrative(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + for _, p := range preamblePrefixes { + if hasPrefixFold(s, p) { + s = s[len(p):] + // A stripped preamble is usually followed by stray punctuation + // ("Here is —", "Sure - ..."). Eat one round of leading + // punctuation/whitespace before returning. + s = strings.TrimLeft(s, " \t:-—–.,") + s = strings.TrimSpace(s) + break + } + } + return s +} + +func hasPrefixFold(s, prefix string) bool { + if len(s) < len(prefix) { + return false + } + return strings.EqualFold(s[:len(prefix)], prefix) +} + +// directionWord describes the sign of the net delta for the prompt's +// "Direction:" line. Centavo-tolerance treats sub-cent fluctuations as +// neutral so the LLM does not make a fuss about floating-point noise. +func directionWord(delta float64) string { + if math.Abs(delta) < centavoTolerance { + return "neutral" + } + if delta > 0 { + return "increase" + } + return "decrease (savings)" +} + +// statsSummary collapses the action counters into a single human line for +// the prompt. Zero-count categories are skipped so the output is tight. +func statsSummary(s Stats) string { + var parts []string + if s.Created > 0 { + parts = append(parts, fmt.Sprintf("%d created", s.Created)) + } + if s.Deleted > 0 { + parts = append(parts, fmt.Sprintf("%d deleted", s.Deleted)) + } + if s.Updated > 0 { + parts = append(parts, fmt.Sprintf("%d updated", s.Updated)) + } + if s.Replaced > 0 { + parts = append(parts, fmt.Sprintf("%d replaced", s.Replaced)) + } + if s.Skipped > 0 { + parts = append(parts, fmt.Sprintf("%d skipped", s.Skipped)) + } + if len(parts) == 0 { + return "no changes" + } + return strings.Join(parts, ", ") +} + +// collectCaveats gathers plan-wide and per-resource notes into a +// deduplicated list, capped at max. Plan-wide notes come first because +// they describe the overall estimate; per-resource notes follow in +// first-seen order. +func collectCaveats(d CostDiff, max int) []string { + out := make([]string, 0, max) + seen := map[string]bool{} + add := func(n string) bool { + if seen[n] { + return false + } + seen[n] = true + out = append(out, n) + return len(out) >= max + } + for _, n := range d.Notes { + if add(n) { + return out + } + } + for _, c := range d.Changes { + for _, n := range c.Notes { + if add(n) { + return out + } + } + } + return out +} diff --git a/internal/diff/narrative_integration_test.go b/internal/diff/narrative_integration_test.go new file mode 100644 index 0000000..a2b4345 --- /dev/null +++ b/internal/diff/narrative_integration_test.go @@ -0,0 +1,158 @@ +//go:build integration + +// Package diff integration tests for the LLM-generated PR narrative. +// +// These tests hit a real LLM provider (Claude via ANTHROPIC_API_KEY) and +// therefore live behind the `integration` build tag. They are not part +// of the default `go test ./...` run; invoke them with +// +// go test -tags=integration ./internal/diff/... +// +// If no API credentials are present, each test calls t.Skip — running +// with the tag but no creds yields a clean skip, not a failure. +package diff + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "CloudOracle/internal/config" + "CloudOracle/internal/llm" +) + +// claudeForIntegration returns a real Claude provider, or skips the +// test if ANTHROPIC_API_KEY is not set. +func claudeForIntegration(t *testing.T) llm.Provider { + t.Helper() + key := os.Getenv("ANTHROPIC_API_KEY") + if key == "" { + t.Skip("ANTHROPIC_API_KEY not set; skipping integration test") + } + p, err := llm.NewProvider(config.LLMConfig{ + Provider: "claude", + ClaudeAPIKey: key, + RequestTimeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("constructing Claude provider: %v", err) + } + return p +} + +// TestIntegration_PRNarrative_Claude exercises the real LLM end-to-end +// with the canonical happy-path fixture. The LLM output is non-deterministic, +// so assertions are shape-based rather than content-equality: +// +// - non-empty after our cleaning step +// - within the 600-char ceiling we promised in the doc +// - does NOT contain the exact total ("$389.35" / "+$389.35"), confirming +// the model honored the "do not repeat the total" instruction +// - mentions at least one of the top-mover types (rds / aurora / +// instance / lambda / ebs / nat), confirming the model is grounded +// in the data we passed +func TestIntegration_PRNarrative_Claude(t *testing.T) { + provider := claudeForIntegration(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + out := RenderMarkdownWithLLM(ctx, happyPathDiff(), provider) + + // Pull out just the narrative line: between "**Net monthly change ..." + // and the next blank-line separator before "### Top movers". + narrative := extractNarrative(t, out) + if narrative == "" { + t.Fatalf("could not extract narrative from rendered output:\n%s", out) + } + t.Logf("LLM narrative: %s", narrative) + + if len(narrative) > 600 { + t.Errorf("narrative exceeded 600 chars (got %d): %s", len(narrative), narrative) + } + + // Sanity: the model must NOT echo the bold total verbatim. + for _, forbidden := range []string{"$389.35", "+$389.35"} { + if strings.Contains(narrative, forbidden) { + t.Errorf("narrative repeats the total %q despite the explicit instruction:\n%s", + forbidden, narrative) + } + } + + // Sanity: the narrative should be grounded in the resources we + // supplied. Match against type substrings rather than full type + // names so the LLM has wiggle room ("Aurora cluster" matches + // "aurora", "RDS instance" matches "rds"). + wantAny := []string{"rds", "aurora", "instance", "lambda", "ebs", "nat", "database", "db"} + hit := false + lower := strings.ToLower(narrative) + for _, w := range wantAny { + if strings.Contains(lower, w) { + hit = true + break + } + } + if !hit { + t.Errorf("narrative does not mention any top-mover type from %v:\n%s", wantAny, narrative) + } +} + +// TestIntegration_PRNarrative_FallbackOnRealError points the Claude +// provider at an obviously-invalid API key and asserts the renderer +// silently falls back to the templated narrative — no error surfaces +// in the rendered comment. +func TestIntegration_PRNarrative_FallbackOnRealError(t *testing.T) { + // Skip if creds are present — this test is about the bad-key path + // and would burn a real API call to no purpose. (Still requires the + // integration tag because constructing the provider hits real net.) + p, err := llm.NewProvider(config.LLMConfig{ + Provider: "claude", + ClaudeAPIKey: "sk-ant-invalid-key-for-integration-test", + RequestTimeout: 10 * time.Second, + }) + if err != nil { + t.Fatalf("constructing Claude provider with stub key: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + d := happyPathDiff() + got := RenderMarkdownWithLLM(ctx, d, p) + want := RenderMarkdown(d) + if got != want { + t.Errorf("invalid-key call should fall back to templated render byte-for-byte\n--- got ---\n%s\n--- want ---\n%s", + got, want) + } +} + +// extractNarrative returns the prose paragraph between the bold net-change +// line and the next major section header in the rendered comment. Used +// by integration tests so we assert against just the LLM output, not +// the surrounding template. +func extractNarrative(t *testing.T, out string) string { + t.Helper() + const startMarker = "**Net monthly change:" + startIdx := strings.Index(out, startMarker) + if startIdx < 0 { + return "" + } + // Skip past the bold line (its trailing newline) to the start of the narrative. + lineEnd := strings.Index(out[startIdx:], "\n") + if lineEnd < 0 { + return "" + } + body := out[startIdx+lineEnd+1:] + body = strings.TrimLeft(body, "\n") + + // The narrative ends at the next blank line before the table heading + // (`### Top movers`) or the breakdown block. + for _, end := range []string{"\n### ", "\n
", "\n---\n"} { + if i := strings.Index(body, end); i >= 0 { + body = body[:i] + } + } + return strings.TrimSpace(body) +} diff --git a/internal/diff/narrative_test.go b/internal/diff/narrative_test.go new file mode 100644 index 0000000..28f83e1 --- /dev/null +++ b/internal/diff/narrative_test.go @@ -0,0 +1,478 @@ +package diff + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "CloudOracle/internal/iac" + "CloudOracle/internal/llm" + "CloudOracle/internal/pricing" + "CloudOracle/internal/shared" +) + +// fakeProvider is a minimal llm.Provider stub used to drive +// RenderMarkdownWithLLM through every branch (success, error, empty, +// oversize, slow, ...) without an HTTP server. +type fakeProvider struct { + name string + response string + err error + delay time.Duration + + // observed prompt — set by GenerateText for assertions + gotPrompt string +} + +func (f *fakeProvider) Name() string { + if f.name == "" { + return "fake" + } + return f.name +} + +func (f *fakeProvider) GenerateSummary(ctx context.Context, _ []shared.Finding) (string, error) { + return f.GenerateText(ctx, "") +} + +func (f *fakeProvider) GenerateText(ctx context.Context, prompt string) (string, error) { + f.gotPrompt = prompt + if f.delay > 0 { + select { + case <-time.After(f.delay): + case <-ctx.Done(): + return "", ctx.Err() + } + } + if f.err != nil { + return "", f.err + } + return f.response, nil +} + +// captureLogs swaps slog's default logger for one that writes to a +// buffer, restoring the previous default on test cleanup. Used to +// assert that the silent fallback still leaves a debugging trail. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// --- BuildPRNarrativePrompt tests --- + +func TestBuildPRNarrativePrompt_HappyPath(t *testing.T) { + d := happyPathDiff() + prompt := BuildPRNarrativePrompt(d) + + // Total monthly delta appears verbatim with the formatting used by + // the renderer. The model should see exactly what the comment shows. + if !strings.Contains(prompt, "+$389.35") { + t.Errorf("prompt missing total delta '+$389.35'; got:\n%s", prompt) + } + if !strings.Contains(prompt, "Direction: increase") { + t.Errorf("prompt missing direction word 'increase'") + } + + // Top three movers should be present by address — the fixture sorts + // aurora ($204.40), db ($71.36), web ($64.74) into the leading slots. + for _, addr := range []string{ + "aws_rds_cluster_instance.aurora", + "aws_db_instance.db", + "aws_instance.web", + } { + if !strings.Contains(prompt, addr) { + t.Errorf("prompt missing top mover %q", addr) + } + } + + // Lower-ranked movers must NOT appear (we cap at three to keep the + // model focused on the dominant items). + if strings.Contains(prompt, "aws_lambda_function.fn") { + t.Errorf("prompt unexpectedly includes 4th+ mover (lambda fn)") + } + + // Notable caveats should surface at least one of the per-resource + // note categories from the fixture. + for _, want := range []string{ + "Operating system assumed Linux", + "Aurora Multi-AZ", + } { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing caveat %q", want) + } + } + + // The three task instructions must be present. + for _, want := range []string{ + "1-3 sentences", + "PRIMARY DRIVER", + "if X, consider Y", + "Output only the prose", + } { + if !strings.Contains(prompt, want) { + t.Errorf("prompt missing task instruction %q", want) + } + } +} + +func TestBuildPRNarrativePrompt_EmptyPlan(t *testing.T) { + d := CostDiff{ + Currency: "USD", + Confidence: pricing.ConfidenceHigh, + } + prompt := BuildPRNarrativePrompt(d) + if !strings.Contains(prompt, "No priceable changes") { + t.Errorf("empty-plan prompt missing 'No priceable changes' phrasing; got:\n%s", prompt) + } + // Even on an empty plan the task block should still tell the model + // what to do — so it produces *something* rather than nothing. + if !strings.Contains(prompt, "Output only the prose") { + t.Errorf("empty-plan prompt missing task block") + } +} + +func TestBuildPRNarrativePrompt_AllSkipped(t *testing.T) { + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: "aws_iam_role", + Action: iac.ActionCreate, + Currency: "USD", + Skipped: true, + SkipReason: "unsupported resource type: aws_iam_role", + } + } + all := []pricing.ChangeEstimate{mk("aws_iam_role.r1"), mk("aws_iam_role.r2")} + d := CostDiff{ + Changes: all, + Skipped: all, + Notes: []string{"2 resources skipped (2 unsupported types, 0 estimation failures)"}, + Stats: Stats{Total: 2, Skipped: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if !strings.Contains(prompt, "No priced resources") { + t.Errorf("all-skipped prompt missing 'No priced resources' phrasing; got:\n%s", prompt) + } +} + +func TestBuildPRNarrativePrompt_NetDecrease(t *testing.T) { + d := CostDiff{ + TotalMonthlyDelta: -120.50, + Changes: []pricing.ChangeEstimate{ + ce("aws_instance.old", "aws_instance", iac.ActionDelete, -120.50, pricing.ConfidenceLow), + }, + Deleted: []pricing.ChangeEstimate{ce("aws_instance.old", "aws_instance", iac.ActionDelete, -120.50, pricing.ConfidenceLow)}, + TopMovers: []pricing.ChangeEstimate{ce("aws_instance.old", "aws_instance", iac.ActionDelete, -120.50, pricing.ConfidenceLow)}, + Confidence: pricing.ConfidenceLow, + Stats: Stats{Total: 1, Deleted: 1, Priced: 1}, + } + prompt := BuildPRNarrativePrompt(d) + + // "decrease" (or "savings", per the spec) must appear in the + // direction line so the model frames the narrative correctly. + lower := strings.ToLower(prompt) + if !strings.Contains(lower, "decrease") && !strings.Contains(lower, "savings") { + t.Errorf("net-decrease prompt missing 'decrease' or 'savings' wording; got:\n%s", prompt) + } + if !strings.Contains(prompt, "-$120.50") { + t.Errorf("net-decrease prompt missing signed delta '-$120.50'") + } +} + +// TestBuildPRNarrativePrompt_GoldenSnapshot writes / compares a stable +// reference file under testdata/narrative_prompts/. The aim isn't strict +// regression: it's to give a human-readable record of what the prompt +// shape looks like for the canonical happy-path case. Run with -update +// to refresh after intentional changes. +func TestBuildPRNarrativePrompt_GoldenSnapshot(t *testing.T) { + prompt := BuildPRNarrativePrompt(happyPathDiff()) + path := filepath.Join("testdata", "narrative_prompts", "happy_path.txt") + if *updateGoldens { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte(prompt), 0o644); err != nil { + t.Fatalf("write golden: %v", err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden %q: %v (run `go test -update ./internal/diff/...` to create it)", path, err) + } + if string(want) != prompt { + t.Errorf("prompt drifted from %s\n--- got ---\n%s\n--- want ---\n%s", path, prompt, string(want)) + } +} + +// --- RenderMarkdownWithLLM tests --- + +func TestRenderMarkdownWithLLM_HappyPath(t *testing.T) { + narrative := "The Aurora cluster instance dominates this change at ~$204/month, roughly half the total; if this is a non-prod environment, an aws_db_instance running db.t3.medium would land around $60/mo for similar functional coverage." + provider := &fakeProvider{response: narrative} + + out := RenderMarkdownWithLLM(context.Background(), happyPathDiff(), provider) + + if !strings.Contains(out, narrative) { + t.Errorf("rendered output missing LLM narrative:\n%s", out) + } + // Templated narrative must NOT be in the output — the LLM version + // replaces it. + if strings.Contains(out, "This plan adds 6 resources") { + t.Errorf("templated narrative leaked into LLM render") + } + if provider.gotPrompt == "" { + t.Errorf("provider was not called") + } +} + +func TestRenderMarkdownWithLLM_NilProvider(t *testing.T) { + d := happyPathDiff() + want := RenderMarkdown(d) + got := RenderMarkdownWithLLM(context.Background(), d, nil) + if got != want { + t.Errorf("nil-provider output should equal RenderMarkdown(d) byte-for-byte\n--- got ---\n%s\n--- want ---\n%s", got, want) + } +} + +func TestRenderMarkdownWithLLM_LLMError(t *testing.T) { + logs := captureLogs(t) + provider := &fakeProvider{ + name: "claude", + err: errors.New("rate limit exceeded"), + } + d := happyPathDiff() + got := RenderMarkdownWithLLM(context.Background(), d, provider) + want := RenderMarkdown(d) + if got != want { + t.Errorf("LLM error should fall back to templated render byte-for-byte") + } + logged := logs.String() + if !strings.Contains(logged, "LLM provider returned error") { + t.Errorf("expected slog.Warn for LLM error; got logs:\n%s", logged) + } + if !strings.Contains(logged, "rate limit exceeded") { + t.Errorf("expected underlying error to appear in logs; got:\n%s", logged) + } +} + +func TestRenderMarkdownWithLLM_EmptyResponse(t *testing.T) { + logs := captureLogs(t) + provider := &fakeProvider{response: ""} + d := happyPathDiff() + got := RenderMarkdownWithLLM(context.Background(), d, provider) + want := RenderMarkdown(d) + if got != want { + t.Errorf("empty LLM response should fall back to templated render") + } + if !strings.Contains(logs.String(), "empty/whitespace") { + t.Errorf("expected slog.Warn for empty response; got:\n%s", logs.String()) + } +} + +func TestRenderMarkdownWithLLM_WhitespaceOnly(t *testing.T) { + provider := &fakeProvider{response: " \n \t\n "} + d := happyPathDiff() + got := RenderMarkdownWithLLM(context.Background(), d, provider) + want := RenderMarkdown(d) + if got != want { + t.Errorf("whitespace-only LLM response should fall back to templated render") + } +} + +func TestRenderMarkdownWithLLM_TooLongResponse(t *testing.T) { + logs := captureLogs(t) + // Build a 1500-char response — the spec caps at ~500. + provider := &fakeProvider{response: strings.Repeat("a", 1500)} + d := happyPathDiff() + got := RenderMarkdownWithLLM(context.Background(), d, provider) + want := RenderMarkdown(d) + if got != want { + t.Errorf("oversize LLM response should fall back to templated render") + } + if !strings.Contains(logs.String(), "exceeded max length") { + t.Errorf("expected slog.Warn for oversize response; got:\n%s", logs.String()) + } +} + +func TestRenderMarkdownWithLLM_TrimsResponse(t *testing.T) { + clean := "The Aurora instance dominates at $204/mo." + provider := &fakeProvider{response: "\n\n " + clean + "\n\n "} + out := RenderMarkdownWithLLM(context.Background(), happyPathDiff(), provider) + + // The cleaned narrative is what the template should render — which + // means the line containing the narrative is exactly `clean` with no + // surrounding extra blank lines (the template inserts its own + // surrounding blank lines). + if !strings.Contains(out, clean) { + t.Errorf("cleaned narrative not in output") + } + if strings.Contains(out, " "+clean) { + t.Errorf("leading whitespace was not trimmed before insertion") + } +} + +func TestRenderMarkdownWithLLM_StripsPreamble(t *testing.T) { + cases := []struct { + name string + raw string + wantSub string + }{ + {"here-is-narrative", "Here is the narrative: The Aurora cluster is the driver.", "The Aurora cluster is the driver."}, + {"heres-narrative", "Here's the narrative: The Aurora cluster is the driver.", "The Aurora cluster is the driver."}, + {"sure-comma", "Sure, the Aurora cluster is the driver.", "the Aurora cluster is the driver."}, + {"of-course", "Of course! The Aurora cluster is the driver.", "The Aurora cluster is the driver."}, + {"certainly", "Certainly. The Aurora cluster is the driver.", "The Aurora cluster is the driver."}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + provider := &fakeProvider{response: c.raw} + out := RenderMarkdownWithLLM(context.Background(), happyPathDiff(), provider) + if !strings.Contains(out, c.wantSub) { + t.Errorf("expected stripped narrative %q in output; got:\n%s", c.wantSub, out) + } + // The preamble itself should not leak through. + if strings.Contains(out, "Here is the narrative:") || strings.Contains(out, "Here's the narrative:") { + t.Errorf("preamble leaked into output:\n%s", out) + } + }) + } +} + +func TestRenderMarkdownWithLLM_ContextCancellation(t *testing.T) { + logs := captureLogs(t) + provider := &fakeProvider{response: "should never be returned", delay: 200 * time.Millisecond} + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + d := happyPathDiff() + got := RenderMarkdownWithLLM(ctx, d, provider) + want := RenderMarkdown(d) + if got != want { + t.Errorf("cancelled context should fall back to templated render") + } + logged := logs.String() + if !strings.Contains(logged, "context already cancelled") && !strings.Contains(logged, "context cancelled") { + t.Errorf("expected slog.Warn for context cancellation; got:\n%s", logged) + } +} + +func TestRenderMarkdownWithLLMConfig_RespectsConfig(t *testing.T) { + provider := &fakeProvider{response: "Aurora drives this."} + d := happyPathDiff() + out := RenderMarkdownWithLLMConfig(context.Background(), d, provider, MarkdownConfig{ + HideFullBreakdown: true, + HideCaveats: true, + CommentMarker: "test-marker", + }) + if strings.Contains(out, "Full breakdown") { + t.Errorf("HideFullBreakdown=true was ignored") + } + if strings.Contains(out, "Assumptions and caveats") { + t.Errorf("HideCaveats=true was ignored") + } + if !strings.Contains(out, "") { + t.Errorf("custom CommentMarker missing") + } + if !strings.Contains(out, "Aurora drives this.") { + t.Errorf("LLM narrative missing under custom config") + } +} + +// --- Compile-time check that fakeProvider satisfies llm.Provider --- + +var _ llm.Provider = (*fakeProvider)(nil) + +// --- cleanNarrative direct unit tests --- + +func TestCleanNarrative(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {" ", ""}, + {"\n\n\t \n", ""}, + {"clean text", "clean text"}, + {" padded ", "padded"}, + {"Here is the narrative: ACTUAL", "ACTUAL"}, + {"here's the narrative: ACTUAL", "ACTUAL"}, + {"Sure, ACTUAL", "ACTUAL"}, + {"Of course. ACTUAL", "ACTUAL"}, + // Preamble matching is anchored at the start; an inline "Sure" doesn't strip. + {"Pricing is sure to surprise.", "Pricing is sure to surprise."}, + } + for _, c := range cases { + if got := cleanNarrative(c.in); got != c.want { + t.Errorf("cleanNarrative(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestDirectionWord(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "neutral"}, + {0.001, "neutral"}, + {-0.001, "neutral"}, + {50, "increase"}, + {-50, "decrease (savings)"}, + } + for _, c := range cases { + if got := directionWord(c.in); got != c.want { + t.Errorf("directionWord(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestStatsSummary(t *testing.T) { + cases := []struct { + in Stats + want string + }{ + {Stats{}, "no changes"}, + {Stats{Created: 1}, "1 created"}, + {Stats{Created: 2, Deleted: 1, Skipped: 3}, "2 created, 1 deleted, 3 skipped"}, + {Stats{Updated: 5, Replaced: 2}, "5 updated, 2 replaced"}, + } + for _, c := range cases { + if got := statsSummary(c.in); got != c.want { + t.Errorf("statsSummary(%+v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestCollectCaveats_DedupesAndCaps(t *testing.T) { + d := CostDiff{ + Notes: []string{"plan-wide A", "plan-wide B"}, + Changes: []pricing.ChangeEstimate{ + withNotes(ce("a", "t", iac.ActionCreate, 1, pricing.ConfidenceLow), "plan-wide A", "per-res X"), + withNotes(ce("b", "t", iac.ActionCreate, 1, pricing.ConfidenceLow), "per-res X", "per-res Y"), + }, + } + got := collectCaveats(d, 10) + want := []string{"plan-wide A", "plan-wide B", "per-res X", "per-res Y"} + if len(got) != len(want) { + t.Fatalf("got %d caveats, want %d (%v vs %v)", len(got), len(want), got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("caveat[%d] = %q, want %q", i, got[i], want[i]) + } + } + + if capped := collectCaveats(d, 2); len(capped) != 2 { + t.Errorf("cap=2 returned %d items: %v", len(capped), capped) + } +} diff --git a/internal/diff/testdata/narrative_prompts/happy_path.txt b/internal/diff/testdata/narrative_prompts/happy_path.txt new file mode 100644 index 0000000..1fd9066 --- /dev/null +++ b/internal/diff/testdata/narrative_prompts/happy_path.txt @@ -0,0 +1,36 @@ +You are reviewing a Terraform pull request as a senior cloud engineer. Your output is a 1-3 sentence narrative that will appear at the top of a PR comment, above a table that already shows the per-resource cost breakdown. + +# Cost change summary +Total monthly delta: +$389.35 +Direction: increase +Stats: 6 created + +# Top resources by impact +- aws_rds_cluster_instance.aurora (aws_rds_cluster_instance, action=create): +$204.40 per month — components: Compute +$204.40 +- aws_db_instance.db (aws_db_instance, action=create): +$71.36 per month — components: Compute +$59.86, Storage +$11.50 +- aws_instance.web (aws_instance, action=create): +$64.74 per month — components: Compute +$60.74, RootEBS +$4.00 + +# Notable assumptions in this estimate +- Net cost increase this plan +- Cluster-level storage and I/O charges not included (priced at aws_rds_cluster) +- Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag +- Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled +- License: No license required (postgres/mysql/mariadb) +- Operating system assumed Linux (plan does not specify) +- Pricing assumes On-Demand (no Reserved Instances or Savings Plans) +- Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled + +# Your task +Write 1-3 sentences that: +1. Identify the PRIMARY DRIVER of cost change (do not summarize the table; pick the dominant resource and explain its weight). +2. If a clear lower-cost alternative exists for the primary driver (smaller instance class, different storage type, etc.), mention it as an "if X, consider Y" — never as a prescription. +3. Optionally note one risk if applicable (e.g., uncovered cost like data processing, license assumption that may not hold). + +DO NOT: +- Repeat the total monthly delta (it's already in the bold above your output). +- List resources by name unless they are the primary driver. +- Use cheerleading language ("great", "looks good", "concerning"). +- Use markdown headings or lists (your output is inline prose only). +- Suggest IaC changes ("you should add..."); only point out cost properties. + +Output only the prose. No preamble. No "Here is the narrative:". Just the 1-3 sentences. \ No newline at end of file diff --git a/internal/llm/claude.go b/internal/llm/claude.go index 410923c..477cdfa 100644 --- a/internal/llm/claude.go +++ b/internal/llm/claude.go @@ -56,8 +56,10 @@ type claudeResponse struct { } func (c *ClaudeProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { - prompt := BuildPrompt(findings) + return c.GenerateText(ctx, BuildPrompt(findings)) +} +func (c *ClaudeProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := claudeRequest{ Model: c.model, MaxTokens: 1024, diff --git a/internal/llm/gemini.go b/internal/llm/gemini.go index 10f1cf3..7c54a8a 100644 --- a/internal/llm/gemini.go +++ b/internal/llm/gemini.go @@ -53,9 +53,10 @@ type geminiResponse struct { } func (g *GeminiProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { + return g.GenerateText(ctx, BuildPrompt(findings)) +} - prompt := BuildPrompt(findings) - +func (g *GeminiProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := geminiRequest{ Contents: []geminiContent{ { diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 15e5d3e..8e416f9 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -55,9 +55,10 @@ type openAIResponse struct { } func (o *OpenAPIProvider) GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) { + return o.GenerateText(ctx, BuildPrompt(findings)) +} - prompt := BuildPrompt(findings) - +func (o *OpenAPIProvider) GenerateText(ctx context.Context, prompt string) (string, error) { reqBody := openAIRequest{ Model: o.model, Messages: []openAIMessage{ diff --git a/internal/llm/provider.go b/internal/llm/provider.go index 03f2381..65c5865 100644 --- a/internal/llm/provider.go +++ b/internal/llm/provider.go @@ -10,7 +10,17 @@ import ( var ErrNoProvider = errors.New("no LLM provider configured") type Provider interface { + // GenerateSummary builds the v1 executive-summary prompt from findings + // and returns the LLM response. Convenience method; thin wrapper over + // GenerateText that calls BuildPrompt for the caller. GenerateSummary(ctx context.Context, findings []shared.Finding) (string, error) + + // GenerateText sends an arbitrary prompt and returns the LLM response. + // The caller owns prompt construction. Used by v2 flows (e.g. PR + // narrative in internal/diff) that build their own context-specific + // prompts and do not want to go through findings shaping. + GenerateText(ctx context.Context, prompt string) (string, error) + Name() string } From 48bb7947c4efe171627da61c61f9ac3958b97c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 22:47:26 -0400 Subject: [PATCH 13/21] feat: enhance caveat handling by grouping assumptions by resource in narrative prompts --- internal/diff/narrative.go | 136 +++++++--- internal/diff/narrative_test.go | 234 +++++++++++++++--- .../testdata/narrative_prompts/happy_path.txt | 19 +- 3 files changed, 314 insertions(+), 75 deletions(-) diff --git a/internal/diff/narrative.go b/internal/diff/narrative.go index 65a10f8..16003c1 100644 --- a/internal/diff/narrative.go +++ b/internal/diff/narrative.go @@ -22,12 +22,6 @@ const maxNarrativeChars = 500 // context the model needs to identify the primary driver. const promptTopMoversN = 3 -// promptCaveatsN caps the assumption list inside the prompt to avoid -// pushing the model into the weeds. Eight is enough to surface the -// notable Linux/Aurora/license assumptions we have today without -// drowning the actual cost data. -const promptCaveatsN = 8 - // preamblePrefixes are common conversational openers some models tack on // despite "no preamble" instructions. Stripped case-insensitively. Order // matters: longer, more specific prefixes are listed first so they match @@ -150,18 +144,7 @@ func BuildPRNarrativePrompt(d CostDiff) string { sb.WriteByte('\n') } - sb.WriteString("# Notable assumptions in this estimate\n") - caveats := collectCaveats(d, promptCaveatsN) - if len(caveats) == 0 { - sb.WriteString("(none)\n\n") - } else { - for _, c := range caveats { - sb.WriteString("- ") - sb.WriteString(c) - sb.WriteByte('\n') - } - sb.WriteByte('\n') - } + writeCaveatsByResource(&sb, d) sb.WriteString("# Your task\n") sb.WriteString("Write 1-3 sentences that:\n") @@ -173,7 +156,8 @@ func BuildPRNarrativePrompt(d CostDiff) string { sb.WriteString("- List resources by name unless they are the primary driver.\n") sb.WriteString("- Use cheerleading language (\"great\", \"looks good\", \"concerning\").\n") sb.WriteString("- Use markdown headings or lists (your output is inline prose only).\n") - sb.WriteString("- Suggest IaC changes (\"you should add...\"); only point out cost properties.\n\n") + sb.WriteString("- Suggest IaC changes (\"you should add...\"); only point out cost properties.\n") + sb.WriteString("- Suggest billing-model alternatives (Reserved Instances, Savings Plans, Spot) — those are pricing levers, not cost-shape alternatives. Limit suggestions to architectural/sizing changes (different instance class, storage type, deployment shape).\n\n") sb.WriteString("Output only the prose. No preamble. No \"Here is the narrative:\". Just the 1-3 sentences.") return sb.String() } @@ -286,32 +270,102 @@ func statsSummary(s Stats) string { return strings.Join(parts, ", ") } -// collectCaveats gathers plan-wide and per-resource notes into a -// deduplicated list, capped at max. Plan-wide notes come first because -// they describe the overall estimate; per-resource notes follow in -// first-seen order. -func collectCaveats(d CostDiff, max int) []string { - out := make([]string, 0, max) - seen := map[string]bool{} - add := func(n string) bool { - if seen[n] { - return false +// caveatGroup is the prompt-shaped view of a CostDiff's notes, +// partitioned so the LLM cannot accidentally attribute one resource's +// caveat to another. +// +// The flat-list approach we used originally caused a real bug: a NAT +// Gateway caveat ("data processing charges (~$0.045/GB) not modeled") +// was attributed by the model to the RDS primary driver because the +// prompt offered no resource-level binding. Grouping by resource and +// labelling sub-blocks explicitly removes that ambiguity. +type caveatGroup struct { + primaryAddress string + primaryNotes []string + otherNotes []resourceNote + planWideNotes []string +} + +// resourceNote pairs a per-resource caveat with the address it belongs +// to so the prompt can render it as "{address}: {note}". The address +// prefix is what tells the LLM "this caveat is for resource X, not the +// primary driver". +type resourceNote struct { + address string + note string +} + +// buildCaveatGroup partitions a CostDiff's notes into the three +// sub-blocks the prompt expects. The primary driver is TopMovers[0] +// when present (TopMovers is already sorted by absolute delta and +// excludes Skipped); when TopMovers is empty there is no primary, and +// every per-resource note ends up in "other". +func buildCaveatGroup(d CostDiff) caveatGroup { + var g caveatGroup + g.planWideNotes = d.Notes + + var primaryAddr string + if len(d.TopMovers) > 0 { + primaryAddr = d.TopMovers[0].ResourceAddress + g.primaryAddress = primaryAddr + g.primaryNotes = d.TopMovers[0].Notes + } + + for _, c := range d.Changes { + // An empty primaryAddr means "no primary driver", in which + // case we should not match changes that happen to have empty + // addresses against it — every change is "other" in that case. + if primaryAddr != "" && c.ResourceAddress == primaryAddr { + continue + } + for _, n := range c.Notes { + g.otherNotes = append(g.otherNotes, resourceNote{c.ResourceAddress, n}) } - seen[n] = true - out = append(out, n) - return len(out) >= max } - for _, n := range d.Notes { - if add(n) { - return out + return g +} + +// isEmpty reports whether the group has no notes at all. When true the +// prompt omits the entire caveats section. +func (g caveatGroup) isEmpty() bool { + return len(g.primaryNotes) == 0 && len(g.otherNotes) == 0 && len(g.planWideNotes) == 0 +} + +// writeCaveatsByResource emits the three caveat sub-blocks (primary, +// other, plan-wide) into sb. Each sub-block is omitted independently +// when its slice is empty; the section as a whole is omitted when all +// three are empty. +func writeCaveatsByResource(sb *strings.Builder, d CostDiff) { + g := buildCaveatGroup(d) + if g.isEmpty() { + return + } + + if len(g.primaryNotes) > 0 { + fmt.Fprintf(sb, "# Notable assumptions for the primary driver (%s)\n", g.primaryAddress) + for _, n := range g.primaryNotes { + sb.WriteString("- ") + sb.WriteString(n) + sb.WriteByte('\n') } + sb.WriteByte('\n') } - for _, c := range d.Changes { - for _, n := range c.Notes { - if add(n) { - return out - } + + if len(g.otherNotes) > 0 { + sb.WriteString("# Other notable assumptions (do NOT attribute to the primary driver)\n") + for _, rn := range g.otherNotes { + fmt.Fprintf(sb, "- %s: %s\n", rn.address, rn.note) } + sb.WriteByte('\n') + } + + if len(g.planWideNotes) > 0 { + sb.WriteString("# Plan-wide notes\n") + for _, n := range g.planWideNotes { + sb.WriteString("- ") + sb.WriteString(n) + sb.WriteByte('\n') + } + sb.WriteByte('\n') } - return out } diff --git a/internal/diff/narrative_test.go b/internal/diff/narrative_test.go index 28f83e1..b4ba130 100644 --- a/internal/diff/narrative_test.go +++ b/internal/diff/narrative_test.go @@ -95,21 +95,37 @@ func TestBuildPRNarrativePrompt_HappyPath(t *testing.T) { } } - // Lower-ranked movers must NOT appear (we cap at three to keep the - // model focused on the dominant items). - if strings.Contains(prompt, "aws_lambda_function.fn") { - t.Errorf("prompt unexpectedly includes 4th+ mover (lambda fn)") + // Lower-ranked movers must NOT appear in the Top resources block + // (we cap at three to keep the model focused on the dominant items). + // fn's caveat may legitimately appear in the "Other" caveat block + // so we narrow the search to the top-resources section. + topStart := strings.Index(prompt, "# Top resources by impact") + topEnd := strings.Index(prompt[topStart:], "\n# ") + if topStart < 0 || topEnd <= 0 { + t.Fatalf("could not locate '# Top resources' section in prompt:\n%s", prompt) + } + topBlock := prompt[topStart : topStart+topEnd] + if strings.Contains(topBlock, "aws_lambda_function.fn") { + t.Errorf("Top resources block unexpectedly includes 4th+ mover (lambda fn):\n%s", topBlock) } - // Notable caveats should surface at least one of the per-resource - // note categories from the fixture. - for _, want := range []string{ - "Operating system assumed Linux", - "Aurora Multi-AZ", - } { - if !strings.Contains(prompt, want) { - t.Errorf("prompt missing caveat %q", want) - } + // Caveats are grouped by resource. The Aurora primary-driver block + // names the address explicitly; "Aurora Multi-AZ" is in its bullets. + if !strings.Contains(prompt, "# Notable assumptions for the primary driver (aws_rds_cluster_instance.aurora)") { + t.Errorf("primary-driver caveat heading missing or wrong; got:\n%s", prompt) + } + if !strings.Contains(prompt, "Aurora Multi-AZ") { + t.Errorf("Aurora Multi-AZ caveat missing") + } + + // Other resources' caveats appear under "Other ..." with explicit + // address prefixes — this is the line that prevents the LLM from + // attributing a NAT or web caveat to the RDS driver. + if !strings.Contains(prompt, "- aws_instance.web: Operating system assumed Linux") { + t.Errorf("web's Linux caveat missing or not attributed to its address; got:\n%s", prompt) + } + if !strings.Contains(prompt, "- aws_nat_gateway.nat: Hourly gateway charge only") { + t.Errorf("NAT gateway caveat must be address-prefixed in the 'Other' block") } // The three task instructions must be present. @@ -453,26 +469,188 @@ func TestStatsSummary(t *testing.T) { } } -func TestCollectCaveats_DedupesAndCaps(t *testing.T) { +// --- Caveat grouping (hotfix to prevent caveat hallucination) --- + +// caveatGroupedDiff returns a deterministic CostDiff used by the +// grouping tests below. Primary driver = aws_instance.web (top mover); +// "other" = aws_db_instance.db with one note; one plan-wide note. +func caveatGroupedDiff() CostDiff { + web := withNotes( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + "Operating system assumed Linux", + "Pricing assumes On-Demand", + ) + db := withNotes( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "License: No license required", + ) + all := []pricing.ChangeEstimate{web, db} + return CostDiff{ + TotalMonthlyDelta: 150, + Currency: "USD", + Changes: all, + Created: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Notes: []string{"Net cost increase this plan"}, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } +} + +func TestBuildPRNarrativePrompt_CaveatsAreGroupedByResource(t *testing.T) { + prompt := BuildPRNarrativePrompt(caveatGroupedDiff()) + + // Sub-block 1: primary header names the address explicitly. + if !strings.Contains(prompt, "# Notable assumptions for the primary driver (aws_instance.web)") { + t.Errorf("primary-driver heading missing or wrong; got:\n%s", prompt) + } + // Primary's own notes appear without an address prefix (the heading + // already binds them). + for _, want := range []string{"- Operating system assumed Linux", "- Pricing assumes On-Demand"} { + if !strings.Contains(prompt, want) { + t.Errorf("primary note %q missing", want) + } + } + + // Sub-block 2: other resources are explicitly attributed via prefix. + if !strings.Contains(prompt, "# Other notable assumptions (do NOT attribute to the primary driver)") { + t.Errorf("'other' heading missing") + } + if !strings.Contains(prompt, "- aws_db_instance.db: License: No license required") { + t.Errorf("'other' note missing its address prefix; got:\n%s", prompt) + } + + // Critical anti-hallucination check: the primary driver's address + // must not appear inside the "Other" sub-block (would defeat the + // purpose of the split). + otherIdx := strings.Index(prompt, "# Other notable assumptions") + planIdx := strings.Index(prompt, "# Plan-wide notes") + if otherIdx < 0 || planIdx < 0 || planIdx < otherIdx { + t.Fatalf("section ordering wrong (other=%d plan=%d)", otherIdx, planIdx) + } + otherBlock := prompt[otherIdx:planIdx] + if strings.Contains(otherBlock, "aws_instance.web:") { + t.Errorf("primary driver leaked into 'Other' block:\n%s", otherBlock) + } + + // Sub-block 3: plan-wide. + if !strings.Contains(prompt, "# Plan-wide notes\n- Net cost increase this plan") { + t.Errorf("plan-wide note missing or in wrong format") + } +} + +func TestBuildPRNarrativePrompt_NoNotesOmitsSection(t *testing.T) { + web := ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow) + all := []pricing.ChangeEstimate{web} d := CostDiff{ - Notes: []string{"plan-wide A", "plan-wide B"}, - Changes: []pricing.ChangeEstimate{ - withNotes(ce("a", "t", iac.ActionCreate, 1, pricing.ConfidenceLow), "plan-wide A", "per-res X"), - withNotes(ce("b", "t", iac.ActionCreate, 1, pricing.ConfidenceLow), "per-res X", "per-res Y"), - }, + TotalMonthlyDelta: 100, + Changes: all, + Created: all, + TopMovers: all, + Confidence: pricing.ConfidenceLow, + Stats: Stats{Total: 1, Created: 1, Priced: 1}, } - got := collectCaveats(d, 10) - want := []string{"plan-wide A", "plan-wide B", "per-res X", "per-res Y"} - if len(got) != len(want) { - t.Fatalf("got %d caveats, want %d (%v vs %v)", len(got), len(want), got, want) + prompt := BuildPRNarrativePrompt(d) + for _, marker := range []string{ + "# Notable assumptions for the primary driver", + "# Other notable assumptions", + "# Plan-wide notes", + } { + if strings.Contains(prompt, marker) { + t.Errorf("expected %q to be omitted when there are no notes; got:\n%s", marker, prompt) + } } - for i := range want { - if got[i] != want[i] { - t.Errorf("caveat[%d] = %q, want %q", i, got[i], want[i]) +} + +func TestBuildPRNarrativePrompt_OnlyPrimaryHasNotes(t *testing.T) { + web := withNotes( + ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow), + "Linux assumed", + ) + db := ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow) + all := []pricing.ChangeEstimate{web, db} + d := CostDiff{ + TotalMonthlyDelta: 150, + Changes: all, + Created: all, + TopMovers: all, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if !strings.Contains(prompt, "# Notable assumptions for the primary driver (aws_instance.web)") { + t.Errorf("primary heading missing") + } + if strings.Contains(prompt, "# Other notable assumptions") { + t.Errorf("'other' block should be omitted when no other resource has notes") + } + if strings.Contains(prompt, "# Plan-wide notes") { + t.Errorf("plan-wide block should be omitted when d.Notes is empty") + } +} + +func TestBuildPRNarrativePrompt_OnlyOthersHaveNotes(t *testing.T) { + web := ce("aws_instance.web", "aws_instance", iac.ActionCreate, 100, pricing.ConfidenceLow) + db := withNotes( + ce("aws_db_instance.db", "aws_db_instance", iac.ActionCreate, 50, pricing.ConfidenceLow), + "License assumed", + ) + all := []pricing.ChangeEstimate{web, db} + d := CostDiff{ + TotalMonthlyDelta: 150, + Changes: all, + Created: all, + TopMovers: all, + Stats: Stats{Total: 2, Created: 2, Priced: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if strings.Contains(prompt, "# Notable assumptions for the primary driver") { + t.Errorf("primary block should be omitted when primary has no notes") + } + if !strings.Contains(prompt, "# Other notable assumptions (do NOT attribute to the primary driver)") { + t.Errorf("'other' heading missing") + } + if !strings.Contains(prompt, "- aws_db_instance.db: License assumed") { + t.Errorf("'other' note missing its address prefix") + } +} + +func TestBuildPRNarrativePrompt_NoTopMovers(t *testing.T) { + // All-skipped plan: no TopMovers, so no primary driver. Only + // plan-wide notes should render. + mk := func(addr string) pricing.ChangeEstimate { + return pricing.ChangeEstimate{ + ResourceAddress: addr, + ResourceType: "aws_iam_role", + Action: iac.ActionCreate, + Skipped: true, + SkipReason: "unsupported", } } + all := []pricing.ChangeEstimate{mk("aws_iam_role.r1"), mk("aws_iam_role.r2")} + d := CostDiff{ + Changes: all, + Skipped: all, + Notes: []string{"2 resources skipped (2 unsupported types, 0 estimation failures)"}, + Stats: Stats{Total: 2, Skipped: 2}, + } + prompt := BuildPRNarrativePrompt(d) + if strings.Contains(prompt, "# Notable assumptions for the primary driver") { + t.Errorf("primary block should be omitted when there is no primary") + } + if strings.Contains(prompt, "# Other notable assumptions") { + t.Errorf("'other' block should be omitted when no resource carries per-resource notes") + } + if !strings.Contains(prompt, "# Plan-wide notes\n- 2 resources skipped") { + t.Errorf("plan-wide notes missing or malformed; got:\n%s", prompt) + } +} - if capped := collectCaveats(d, 2); len(capped) != 2 { - t.Errorf("cap=2 returned %d items: %v", len(capped), capped) +func TestBuildPRNarrativePrompt_ContainsBillingModelDoNot(t *testing.T) { + prompt := BuildPRNarrativePrompt(happyPathDiff()) + if !strings.Contains(prompt, "Suggest billing-model alternatives") { + t.Errorf("new billing-model DO NOT rule missing from prompt") + } + if !strings.Contains(prompt, "Reserved Instances") { + t.Errorf("billing-model rule should name the things it forbids (RI/SP/Spot)") } } diff --git a/internal/diff/testdata/narrative_prompts/happy_path.txt b/internal/diff/testdata/narrative_prompts/happy_path.txt index 1fd9066..dda2222 100644 --- a/internal/diff/testdata/narrative_prompts/happy_path.txt +++ b/internal/diff/testdata/narrative_prompts/happy_path.txt @@ -10,15 +10,21 @@ Stats: 6 created - aws_db_instance.db (aws_db_instance, action=create): +$71.36 per month — components: Compute +$59.86, Storage +$11.50 - aws_instance.web (aws_instance, action=create): +$64.74 per month — components: Compute +$60.74, RootEBS +$4.00 -# Notable assumptions in this estimate -- Net cost increase this plan +# Notable assumptions for the primary driver (aws_rds_cluster_instance.aurora) - Cluster-level storage and I/O charges not included (priced at aws_rds_cluster) - Aurora Multi-AZ is via reader replicas (multiple aws_rds_cluster_instance), not a per-instance flag - Pricing assumes standard Aurora mode (storage=EBS Only); I/O Optimization Mode is not modeled -- License: No license required (postgres/mysql/mariadb) -- Operating system assumed Linux (plan does not specify) -- Pricing assumes On-Demand (no Reserved Instances or Savings Plans) -- Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled + +# Other notable assumptions (do NOT attribute to the primary driver) +- aws_db_instance.db: License: No license required (postgres/mysql/mariadb) +- aws_instance.web: Operating system assumed Linux (plan does not specify) +- aws_instance.web: Pricing assumes On-Demand (no Reserved Instances or Savings Plans) +- aws_nat_gateway.nat: Hourly gateway charge only; per-GB data processing charges (~$0.045/GB) not modeled +- aws_ebs_volume.disk: IOPS-month and throughput-month charges not included for gp3 above defaults (3000 IOPS, 125 MB/s) +- aws_lambda_function.fn: Standing cost is $0; per-invocation charges (requests + GB-seconds) not modeled + +# Plan-wide notes +- Net cost increase this plan # Your task Write 1-3 sentences that: @@ -32,5 +38,6 @@ DO NOT: - Use cheerleading language ("great", "looks good", "concerning"). - Use markdown headings or lists (your output is inline prose only). - Suggest IaC changes ("you should add..."); only point out cost properties. +- Suggest billing-model alternatives (Reserved Instances, Savings Plans, Spot) — those are pricing levers, not cost-shape alternatives. Limit suggestions to architectural/sizing changes (different instance class, storage type, deployment shape). Output only the prose. No preamble. No "Here is the narrative:". Just the 1-3 sentences. \ No newline at end of file From 3a425170a5c1a003b1e00fd4d1e6b580287210c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 22:47:58 -0400 Subject: [PATCH 14/21] chore: remove legacy `checkpoint135` code, Terraform configuration, and resources --- checkpoint/.terraform.lock.hcl | 25 -- .../aws/5.100.0/windows_amd64/LICENSE.txt | 375 ------------------ checkpoint/main.tf | 63 --- checkpoint/real_plan.json | 1 - checkpoint/tf.plan | Bin 5206 -> 0 bytes cmd/checkpoint135/main.go | 78 ---- 6 files changed, 542 deletions(-) delete mode 100644 checkpoint/.terraform.lock.hcl delete mode 100644 checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt delete mode 100644 checkpoint/main.tf delete mode 100644 checkpoint/real_plan.json delete mode 100644 checkpoint/tf.plan delete mode 100644 cmd/checkpoint135/main.go diff --git a/checkpoint/.terraform.lock.hcl b/checkpoint/.terraform.lock.hcl deleted file mode 100644 index 92a2bcc..0000000 --- a/checkpoint/.terraform.lock.hcl +++ /dev/null @@ -1,25 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "5.100.0" - constraints = "~> 5.0" - hashes = [ - "h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=", - "zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644", - "zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2", - "zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274", - "zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b", - "zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862", - "zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93", - "zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2", - "zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e", - "zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421", - "zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4", - "zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9", - "zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9", - "zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70", - ] -} diff --git a/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt b/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt deleted file mode 100644 index b9ac071..0000000 --- a/checkpoint/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt +++ /dev/null @@ -1,375 +0,0 @@ -Copyright (c) 2017 HashiCorp, Inc. - -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at http://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/checkpoint/main.tf b/checkpoint/main.tf deleted file mode 100644 index 0fc771d..0000000 --- a/checkpoint/main.tf +++ /dev/null @@ -1,63 +0,0 @@ -terraform { - required_providers { - aws = { source = "hashicorp/aws", version = "~> 5.0" } - } -} - -provider "aws" { - region = "us-east-2" - skip_credentials_validation = true - skip_requesting_account_id = true - skip_metadata_api_check = true - access_key = "test" - secret_key = "test" -} - -resource "aws_instance" "web" { - ami = "ami-12345" - instance_type = "t3.large" - - root_block_device { - volume_size = 50 - volume_type = "gp3" - } -} -resource "aws_db_instance" "main" { - identifier = "main" - engine = "postgres" - instance_class = "db.t3.medium" - allocated_storage = 100 - username = "admin" - password = "changeme" - skip_final_snapshot = true -} - -resource "aws_ebs_volume" "data" { - availability_zone = "us-east-2a" - size = 200 - type = "gp3" - throughput = 125 -} - -resource "aws_lambda_function" "worker" { - function_name = "worker" - role = "arn:aws:iam::123456789012:role/lambda" - handler = "index.handler" - runtime = "python3.12" - memory_size = 512 - timeout = 30 - filename = "dummy.zip" - architectures = ["arm64"] -} - -resource "aws_nat_gateway" "main" { - allocation_id = "eipalloc-12345" - subnet_id = "subnet-12345" -} - -resource "aws_rds_cluster_instance" "replica" { - identifier = "replica" - cluster_identifier = "main-cluster" - instance_class = "db.r5.large" - engine = "aurora-postgresql" -} diff --git a/checkpoint/real_plan.json b/checkpoint/real_plan.json deleted file mode 100644 index 46e3fec..0000000 --- a/checkpoint/real_plan.json +++ /dev/null @@ -1 +0,0 @@ -{"format_version":"1.2","terraform_version":"1.15.2","planned_values":{"root_module":{"resources":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":2,"values":{"allocated_storage":100,"allow_major_version_upgrade":null,"apply_immediately":false,"auto_minor_version_upgrade":true,"blue_green_update":[],"copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"customer_owned_ip_enabled":null,"dedicated_log_volume":false,"delete_automated_backups":true,"deletion_protection":null,"domain":null,"domain_auth_secret_arn":null,"domain_dns_ips":null,"domain_iam_role_name":null,"domain_ou":null,"enabled_cloudwatch_logs_exports":null,"engine":"postgres","final_snapshot_identifier":null,"iam_database_authentication_enabled":null,"identifier":"main","instance_class":"db.t3.medium","manage_master_user_password":null,"max_allocated_storage":null,"monitoring_interval":0,"password":"changeme","password_wo":null,"password_wo_version":null,"performance_insights_enabled":false,"publicly_accessible":false,"replicate_source_db":null,"restore_to_point_in_time":[],"s3_import":[],"skip_final_snapshot":true,"storage_encrypted":null,"tags":null,"timeouts":null,"upgrade_storage_config":null,"username":"admin"},"sensitive_values":{"blue_green_update":[],"listener_endpoint":[],"master_user_secret":[],"password":true,"password_wo":true,"replicas":[],"restore_to_point_in_time":[],"s3_import":[],"tags_all":{},"vpc_security_group_ids":[]}},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"availability_zone":"us-east-2a","final_snapshot":false,"multi_attach_enabled":null,"outpost_arn":null,"size":200,"tags":null,"throughput":125,"timeouts":null,"type":"gp3"},"sensitive_values":{"tags_all":{}}},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":1,"values":{"ami":"ami-12345","credit_specification":[],"get_password_data":false,"hibernation":null,"instance_type":"t3.large","launch_template":[],"root_block_device":[{"delete_on_termination":true,"tags":null,"volume_size":50,"volume_type":"gp3"}],"source_dest_check":true,"tags":null,"timeouts":null,"user_data_replace_on_change":false,"volume_tags":null},"sensitive_values":{"capacity_reservation_specification":[],"cpu_options":[],"credit_specification":[],"ebs_block_device":[],"enclave_options":[],"ephemeral_block_device":[],"instance_market_options":[],"ipv6_addresses":[],"launch_template":[],"maintenance_options":[],"metadata_options":[],"network_interface":[],"private_dns_name_options":[],"root_block_device":[{"tags_all":{}}],"secondary_private_ips":[],"security_groups":[],"tags_all":{},"vpc_security_group_ids":[]}},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"architectures":["arm64"],"code_signing_config_arn":null,"dead_letter_config":[],"description":null,"environment":[],"file_system_config":[],"filename":"dummy.zip","function_name":"worker","handler":"index.handler","image_config":[],"image_uri":null,"kms_key_arn":null,"layers":null,"memory_size":512,"package_type":"Zip","publish":false,"replace_security_groups_on_destroy":null,"replacement_security_group_ids":null,"reserved_concurrent_executions":-1,"role":"arn:aws:iam::123456789012:role/lambda","runtime":"python3.12","s3_bucket":null,"s3_key":null,"s3_object_version":null,"skip_destroy":false,"snap_start":[],"tags":null,"timeout":30,"timeouts":null,"vpc_config":[]},"sensitive_values":{"architectures":[false],"dead_letter_config":[],"environment":[],"ephemeral_storage":[],"file_system_config":[],"image_config":[],"logging_config":[],"snap_start":[],"tags_all":{},"tracing_config":[],"vpc_config":[]}},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"allocation_id":"eipalloc-12345","connectivity_type":"public","secondary_allocation_ids":null,"subnet_id":"subnet-12345","tags":null,"timeouts":null},"sensitive_values":{"secondary_private_ip_addresses":[],"tags_all":{}}},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"auto_minor_version_upgrade":true,"cluster_identifier":"main-cluster","copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"engine":"aurora-postgresql","force_destroy":false,"identifier":"replica","instance_class":"db.r5.large","monitoring_interval":0,"promotion_tier":0,"tags":null,"timeouts":null},"sensitive_values":{"tags_all":{}}}]}},"resource_changes":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"allocated_storage":100,"allow_major_version_upgrade":null,"apply_immediately":false,"auto_minor_version_upgrade":true,"blue_green_update":[],"copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"customer_owned_ip_enabled":null,"dedicated_log_volume":false,"delete_automated_backups":true,"deletion_protection":null,"domain":null,"domain_auth_secret_arn":null,"domain_dns_ips":null,"domain_iam_role_name":null,"domain_ou":null,"enabled_cloudwatch_logs_exports":null,"engine":"postgres","final_snapshot_identifier":null,"iam_database_authentication_enabled":null,"identifier":"main","instance_class":"db.t3.medium","manage_master_user_password":null,"max_allocated_storage":null,"monitoring_interval":0,"password":"changeme","password_wo":null,"password_wo_version":null,"performance_insights_enabled":false,"publicly_accessible":false,"replicate_source_db":null,"restore_to_point_in_time":[],"s3_import":[],"skip_final_snapshot":true,"storage_encrypted":null,"tags":null,"timeouts":null,"upgrade_storage_config":null,"username":"admin"},"after_unknown":{"address":true,"arn":true,"availability_zone":true,"backup_retention_period":true,"backup_target":true,"backup_window":true,"blue_green_update":[],"ca_cert_identifier":true,"character_set_name":true,"database_insights_mode":true,"db_name":true,"db_subnet_group_name":true,"domain_fqdn":true,"endpoint":true,"engine_lifecycle_support":true,"engine_version":true,"engine_version_actual":true,"hosted_zone_id":true,"id":true,"identifier_prefix":true,"iops":true,"kms_key_id":true,"latest_restorable_time":true,"license_model":true,"listener_endpoint":true,"maintenance_window":true,"master_user_secret":true,"master_user_secret_kms_key_id":true,"monitoring_role_arn":true,"multi_az":true,"nchar_character_set_name":true,"network_type":true,"option_group_name":true,"parameter_group_name":true,"performance_insights_kms_key_id":true,"performance_insights_retention_period":true,"port":true,"replica_mode":true,"replicas":true,"resource_id":true,"restore_to_point_in_time":[],"s3_import":[],"snapshot_identifier":true,"status":true,"storage_throughput":true,"storage_type":true,"tags_all":true,"timezone":true,"vpc_security_group_ids":true},"before_sensitive":false,"after_sensitive":{"blue_green_update":[],"listener_endpoint":[],"master_user_secret":[],"password":true,"password_wo":true,"replicas":[],"restore_to_point_in_time":[],"s3_import":[],"tags_all":{},"vpc_security_group_ids":[]}}},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"availability_zone":"us-east-2a","final_snapshot":false,"multi_attach_enabled":null,"outpost_arn":null,"size":200,"tags":null,"throughput":125,"timeouts":null,"type":"gp3"},"after_unknown":{"arn":true,"create_time":true,"encrypted":true,"id":true,"iops":true,"kms_key_id":true,"snapshot_id":true,"tags_all":true},"before_sensitive":false,"after_sensitive":{"tags_all":{}}}},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"ami":"ami-12345","credit_specification":[],"get_password_data":false,"hibernation":null,"instance_type":"t3.large","launch_template":[],"root_block_device":[{"delete_on_termination":true,"tags":null,"volume_size":50,"volume_type":"gp3"}],"source_dest_check":true,"tags":null,"timeouts":null,"user_data_replace_on_change":false,"volume_tags":null},"after_unknown":{"arn":true,"associate_public_ip_address":true,"availability_zone":true,"capacity_reservation_specification":true,"cpu_core_count":true,"cpu_options":true,"cpu_threads_per_core":true,"credit_specification":[],"disable_api_stop":true,"disable_api_termination":true,"ebs_block_device":true,"ebs_optimized":true,"enable_primary_ipv6":true,"enclave_options":true,"ephemeral_block_device":true,"host_id":true,"host_resource_group_arn":true,"iam_instance_profile":true,"id":true,"instance_initiated_shutdown_behavior":true,"instance_lifecycle":true,"instance_market_options":true,"instance_state":true,"ipv6_address_count":true,"ipv6_addresses":true,"key_name":true,"launch_template":[],"maintenance_options":true,"metadata_options":true,"monitoring":true,"network_interface":true,"outpost_arn":true,"password_data":true,"placement_group":true,"placement_partition_number":true,"primary_network_interface_id":true,"private_dns":true,"private_dns_name_options":true,"private_ip":true,"public_dns":true,"public_ip":true,"root_block_device":[{"device_name":true,"encrypted":true,"iops":true,"kms_key_id":true,"tags_all":true,"throughput":true,"volume_id":true}],"secondary_private_ips":true,"security_groups":true,"spot_instance_request_id":true,"subnet_id":true,"tags_all":true,"tenancy":true,"user_data":true,"user_data_base64":true,"vpc_security_group_ids":true},"before_sensitive":false,"after_sensitive":{"capacity_reservation_specification":[],"cpu_options":[],"credit_specification":[],"ebs_block_device":[],"enclave_options":[],"ephemeral_block_device":[],"instance_market_options":[],"ipv6_addresses":[],"launch_template":[],"maintenance_options":[],"metadata_options":[],"network_interface":[],"private_dns_name_options":[],"root_block_device":[{"tags_all":{}}],"secondary_private_ips":[],"security_groups":[],"tags_all":{},"vpc_security_group_ids":[]}}},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"architectures":["arm64"],"code_signing_config_arn":null,"dead_letter_config":[],"description":null,"environment":[],"file_system_config":[],"filename":"dummy.zip","function_name":"worker","handler":"index.handler","image_config":[],"image_uri":null,"kms_key_arn":null,"layers":null,"memory_size":512,"package_type":"Zip","publish":false,"replace_security_groups_on_destroy":null,"replacement_security_group_ids":null,"reserved_concurrent_executions":-1,"role":"arn:aws:iam::123456789012:role/lambda","runtime":"python3.12","s3_bucket":null,"s3_key":null,"s3_object_version":null,"skip_destroy":false,"snap_start":[],"tags":null,"timeout":30,"timeouts":null,"vpc_config":[]},"after_unknown":{"architectures":[false],"arn":true,"code_sha256":true,"dead_letter_config":[],"environment":[],"ephemeral_storage":true,"file_system_config":[],"id":true,"image_config":[],"invoke_arn":true,"last_modified":true,"logging_config":true,"qualified_arn":true,"qualified_invoke_arn":true,"signing_job_arn":true,"signing_profile_version_arn":true,"snap_start":[],"source_code_hash":true,"source_code_size":true,"tags_all":true,"tracing_config":true,"version":true,"vpc_config":[]},"before_sensitive":false,"after_sensitive":{"architectures":[false],"dead_letter_config":[],"environment":[],"ephemeral_storage":[],"file_system_config":[],"image_config":[],"logging_config":[],"snap_start":[],"tags_all":{},"tracing_config":[],"vpc_config":[]}}},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"allocation_id":"eipalloc-12345","connectivity_type":"public","secondary_allocation_ids":null,"subnet_id":"subnet-12345","tags":null,"timeouts":null},"after_unknown":{"association_id":true,"id":true,"network_interface_id":true,"private_ip":true,"public_ip":true,"secondary_private_ip_address_count":true,"secondary_private_ip_addresses":true,"tags_all":true},"before_sensitive":false,"after_sensitive":{"secondary_private_ip_addresses":[],"tags_all":{}}}},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"auto_minor_version_upgrade":true,"cluster_identifier":"main-cluster","copy_tags_to_snapshot":false,"custom_iam_instance_profile":null,"engine":"aurora-postgresql","force_destroy":false,"identifier":"replica","instance_class":"db.r5.large","monitoring_interval":0,"promotion_tier":0,"tags":null,"timeouts":null},"after_unknown":{"apply_immediately":true,"arn":true,"availability_zone":true,"ca_cert_identifier":true,"db_parameter_group_name":true,"db_subnet_group_name":true,"dbi_resource_id":true,"endpoint":true,"engine_version":true,"engine_version_actual":true,"id":true,"identifier_prefix":true,"kms_key_id":true,"monitoring_role_arn":true,"network_type":true,"performance_insights_enabled":true,"performance_insights_kms_key_id":true,"performance_insights_retention_period":true,"port":true,"preferred_backup_window":true,"preferred_maintenance_window":true,"publicly_accessible":true,"storage_encrypted":true,"tags_all":true,"writer":true},"before_sensitive":false,"after_sensitive":{"tags_all":{}}}}],"configuration":{"provider_config":{"aws":{"name":"aws","full_name":"registry.terraform.io/hashicorp/aws","version_constraint":"~\u003e 5.0","expressions":{"access_key":{"constant_value":"test"},"region":{"constant_value":"us-east-2"},"secret_key":{"constant_value":"test"},"skip_credentials_validation":{"constant_value":true},"skip_metadata_api_check":{"constant_value":true},"skip_requesting_account_id":{"constant_value":true}}}},"root_module":{"resources":[{"address":"aws_db_instance.main","mode":"managed","type":"aws_db_instance","name":"main","provider_config_key":"aws","expressions":{"allocated_storage":{"constant_value":100},"engine":{"constant_value":"postgres"},"identifier":{"constant_value":"main"},"instance_class":{"constant_value":"db.t3.medium"},"password":{"constant_value":"changeme"},"skip_final_snapshot":{"constant_value":true},"username":{"constant_value":"admin"}},"schema_version":2},{"address":"aws_ebs_volume.data","mode":"managed","type":"aws_ebs_volume","name":"data","provider_config_key":"aws","expressions":{"availability_zone":{"constant_value":"us-east-2a"},"size":{"constant_value":200},"throughput":{"constant_value":125},"type":{"constant_value":"gp3"}},"schema_version":0},{"address":"aws_instance.web","mode":"managed","type":"aws_instance","name":"web","provider_config_key":"aws","expressions":{"ami":{"constant_value":"ami-12345"},"instance_type":{"constant_value":"t3.large"},"root_block_device":[{"volume_size":{"constant_value":50},"volume_type":{"constant_value":"gp3"}}]},"schema_version":1},{"address":"aws_lambda_function.worker","mode":"managed","type":"aws_lambda_function","name":"worker","provider_config_key":"aws","expressions":{"architectures":{"constant_value":["arm64"]},"filename":{"constant_value":"dummy.zip"},"function_name":{"constant_value":"worker"},"handler":{"constant_value":"index.handler"},"memory_size":{"constant_value":512},"role":{"constant_value":"arn:aws:iam::123456789012:role/lambda"},"runtime":{"constant_value":"python3.12"},"timeout":{"constant_value":30}},"schema_version":0},{"address":"aws_nat_gateway.main","mode":"managed","type":"aws_nat_gateway","name":"main","provider_config_key":"aws","expressions":{"allocation_id":{"constant_value":"eipalloc-12345"},"subnet_id":{"constant_value":"subnet-12345"}},"schema_version":0},{"address":"aws_rds_cluster_instance.replica","mode":"managed","type":"aws_rds_cluster_instance","name":"replica","provider_config_key":"aws","expressions":{"cluster_identifier":{"constant_value":"main-cluster"},"engine":{"constant_value":"aurora-postgresql"},"identifier":{"constant_value":"replica"},"instance_class":{"constant_value":"db.r5.large"}},"schema_version":0}]}},"timestamp":"2026-05-08T17:48:38Z","applyable":true,"complete":true,"errored":false} diff --git a/checkpoint/tf.plan b/checkpoint/tf.plan deleted file mode 100644 index 6d513cdd08375d2add061cf3cc225e7673dbbd60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5206 zcmd5=WmJ@F)Ez>)yJG;!p&O(nh7J)4=@??@Msny5DG5OkP(Zr7L+Kika_AJ45ClHF z-?~@tUEjUGzx}N9<5}-IXYKW__w2LZqos<1N&>*RJH*gZW591f17HF?Y+M}8p^xDzi~|luvapgVJK&D1j)ppI(wZ<4W$xc zHeel}eK-tgKflV%@`Lyt71NteD%Rga|N1pMB)w|PV-GK`K;1f}Me8xpo~b)+^0D3E zT;OqPklt&KjLuR$O-{{F=RoE7ee`ZACtbp6t-c=5VJ8-IiFyH*{KmhnT|UndFP*Vl1k+R%_9pfDi@F9s4gzGF@=m&v%;HcuJQAlRj`CPY%{ zrSO^4Et?(@my3TtSKfphblvv&EF-9C({9w}#f5Z%YaDIsP-z|M&dd_L&$ny_?)bq| zC}3eKey_8<&4h_a92F`iA04ltV-$EbMlF}DUY`eK>=4w;cTqihfV8DhjD-+;Qlo}{H_0tVZQ=A24W2cn`zYXRWwQL ziQ`&!p9s=|U1+WKqmuB{Qu&9Z7v~h~_z-fI9bYw1A}z7iR5a8x;?(kfRAFmkayfMC+?cZ%B&qsZCq+5aDCR9N-)L4lI;BBQV5>uadl8ePKd}WzqF! zCat&$GsS@=V5dCH5fk%DP-tX=k~uyd$zNc3lzN1A8@)U17^C2QF^PjsV_B6m-W=sD zXxrYuX&)CwoGk7TUtWRQF2+dNE9_xSrr{#Oe95?CaHh^IX9tgnC0ti=NI#)MIDRlx zv0vtdmv!RIV&l^7RouP2eIh_qq|o&vg({Q~bht6tMI&A&)MigeIPvE!BVwV54R zos;Sl3gzokQHF!xs3<8{iU&%C-RHtYpE?(}TbWj58S&mj&0J+DiEUD4vox!~ur!|Q z@En;SOVU2|A$vV9p(tvaJHK*WGx5yR9N0DvQ}sys{GhLKEgfeW>nIAm)h2rk?5zuZ z6v&>jB`Q-DD`b9+S>R29#ADBL3tno%=aOi(oXn9%r=Qu;KPD(wp~lcPo|Mc)IT{T$ z7_u`?a=xX^sHN83Q!W$@C6suQ2qBOAaAq-Uk2{~R!ji-`&0+_<@nT$>_#wT!-3}9BlqW>0*3v z(gJj~=J;~W@>c^D%bXM+A2ZCkOJfXJWCllnS!P?kUSb?O&na-^il>&RTY)<0r;ldX z?%t36VWsP>h$u-$Inhh1x!m(z)3kjjC4Jf1B>1Q~AfW7Wbt-k$mO32wBtp{rahIyW zi4w(b5^PzN=M3(f>T*&@wq{-cAYQjDf(`;?X zxjZIb$#f2R7O-mcBakwjB%#V^3+s`9#K`SuTS7Nj$d9ht$9>Ip3U)6}W41Qc4}Hsg z`ojIkgWX3PVoJfa<1xiVWN*{FODze<=7^fYr=Yc%X!f-p8iV1gF%O9iG8i{XT3k@a z*UF^mq)`&q#TH}J`XBU6akLGeyMn^nRs&ZT`(+E&K8cw&L}1?psloB^_bRoOD2j&H z-M6*XXbmDYhzm#u!d@LfxhRyeJHRfGtLiE|W(xkA_;c3Fmr9t=*@Q7qml^6sRqUOV zDD#%+<2QGX(7R2?lm_EJg~67ATbT_0bpFjxXj+^RYwE!5A%*qa`hu&G7vTa)J~0I= zT;wgoKn|lMnRjtX2_txlbZDX9)bjmLo;Q6`nfqo{!t2Z;;7B%ZAcEfSj9!!^LTA%{ zOg!THR52K~!3o3I*@KXVKVcR<+YOnYa?O#H$_%DZDD#{t_A0a;JIp9pyBC$Q%b(49 z^rYkCEl251DM4sW4OZC);c6gXBfWMWhA)gGV0qzN*vm6;yAcwZ~&iWf0Xh!K_b792?nh9=pG0UaDS_zv- z;njBxcd^6X6eF<|4Fp2{I|Gv}v+bCO>{}GJh4V}gL#_CndWVb?BW#leat|B3a2ICY z4yy1FF_M*Kz;S1?XcFx~%auFhj7SqJfo*QD_KDQ3P9qG`RF zC{blWti|2#Qb}A^R~{rK*DXbhMfne1>QdgcQ5%A84`PYwRgv+eB`+^36bHp{Aoe*! zno$rUKfkM@@~{$Psk0FW*~1ba$&>92N%8Ay|B(Y^qwn69jp>~QQN7t9K=Id!R zpxwdtw)Rg;)uV^QUZ1lXrxzX`(!|b^;`_*D^U#kt?4hR~D!O*H+G^xKrN)w#W9A9_71ne?6U|)r|oism4gIP)iaBLSwOq9C{6W)$~)=$yTkZ zL}~VRu-vY_O%KYT&_J#@Ndu!m_d47msxF1D))sRojDjnsD z7_zRF5FYGK5bs0Y&8p8vrF~A|r;``y?1Fdd{24|w)M|Wr#8YilY42JAYto;8lWj`u zIiQi-5_j{>K_RyXnX#|`btMc#iK@tAW*ZoSd4d`t*jH_`4ijMeI!G|=NtAXn{7}VE zGK5QUfN@i5|yD@_7JDKO0J(G2&q+{6v1pr_cR5YFsH|G#@0~@ z)HQ`Lsj4$@Zdmbd9uJTo7}ZKkTr5U9tg5U@SXP2ly-UzRM3ZPl znqx!=-&0uyko-3sTCxSd=Yz^f@R0rz?w1Ec7QqB#d8fK2r>o+1K1QV|V!ZsK6mmxh z$J5fOivkDuN-Cl(%!O*s)>vyeWy|xIgg2_OcbNLAqo9wrzKvVFVADw{}wXemcQA zUU>^mSzfGFiKl38?E;%pEDl{@wU~5|;kgPUFbIbLVW!-jL1}-O1ddun@m@`WfPO7G$hCmI!cH9A^4Qz0%U{IN2|4_LVGg&S5H$o!>Rxy25&?02`H@$X9T zv$426tliwqZJgblxE-A>9k}f*9sg=9y=l!@8YO(RFINFEHUZDzj~=PKN$EafWDM%U zQ&GbSNea_TsiF0<-OqY5xR(@g#>)ruqBoM1{wA{nKbcnpP4I`fOg|v&DegoAW74^C+mof zW0FyX(Y$JovsY_l3F2xNQMa#oyy(}FlLx(Fa-KsRNP)eQz?1d@Sv^v!1?LQ zH6YQn<7RW?;z3}*^+|8!<=1)oPQP>e>zk#6x0m4+muo*PZaX!r6TQbnCL$!j$OUSX zQnm+Am66M4p-Y)Iz?Yo&Aou3w?2v`i2piK>8fi3;K45>ha|cprp@76Dl8Oky#JXcs z%1CJASmtDop7Nwrh4uUnHA8}|^b%o&&Mg3&`AKgT0hu`BN8*JF%xx{?;}lDJyxAm zc^|X=T5@^RGosc@SRpXFfWt|?Oc4yqDzwk;9Wk5i9@FVbtrALjQyRB3Rv|N#&M_B;et)b| z8`ySczdzZI90T9Rs-G5=5!su5-t5ZXbvhySil|wOXR@YY0GQ6TIu~QyT05{$BIwtS zg~h#O;^9`8J-24MJHKSw8g$q0|LY!L+Z@AEC;&jvy`O!EgiHeXwWIpoS^V5l{T=_< zSp75k_fh}TeEfpdomug)`MVYQuk4?8;1>w6|H}TmIrwLZ-(~Hmj{HLRA0_^MT>rDu z?^^X!27jR#?") - } - - ctx := context.Background() - - plan, err := iac.ParsePlanFile(os.Args[1]) - if err != nil { - log.Fatalf("parse: %v", err) - } - - client, err := pricing.NewClient(ctx) - if err != nil { - log.Fatalf("NewClient: %v", err) - } - - fmt.Printf("Plan parsed: %d resource changes\n\n", len(plan.ResourceChanges)) - - estimates := make([]pricing.ChangeEstimate, 0, len(plan.ResourceChanges)) - var totalDelta float64 - for _, rc := range plan.ResourceChanges { - est, err := pricing.EstimateChange(ctx, client, rc, "us-east-2") - if err != nil { - fmt.Printf("[FAIL] %s (%s): %v\n", rc.Address, rc.Type, err) - continue - } - estimates = append(estimates, est) - totalDelta += est.MonthlyDelta - } - - sort.Slice(estimates, func(i, j int) bool { - return math.Abs(estimates[i].MonthlyDelta) > math.Abs(estimates[j].MonthlyDelta) - }) - - fmt.Printf("%-50s %-10s %-12s %-10s\n", "RESOURCE", "ACTION", "DELTA/MO", "CONFIDENCE") - fmt.Println(string(make([]byte, 90))) - for _, est := range estimates { - marker := "" - if est.Skipped { - marker = " (skipped: " + est.SkipReason + ")" - } - fmt.Printf("%-50s %-10s $%-11.2f %-10s%s\n", - est.ResourceAddress, est.Action, est.MonthlyDelta, est.Confidence, marker) - } - - fmt.Printf("\n=== Total monthly delta: $%.2f ===\n\n", totalDelta) - - fmt.Println("--- Detailed breakdowns ---") - for _, est := range estimates { - if est.Skipped { - continue - } - fmt.Printf("\n%s (%s, action=%s):\n", est.ResourceAddress, est.ResourceType, est.Action) - fmt.Printf(" Before: $%.4f | After: $%.4f | Delta: $%.4f\n", - est.BeforeMonthly, est.AfterMonthly, est.MonthlyDelta) - for _, item := range est.Breakdown { - fmt.Printf(" %-12s $%.4f\n", item.Component, item.MonthlyUSD) - } - for _, note := range est.Notes { - fmt.Printf(" - %s\n", note) - } - } -} From 6beea5c04423022f6f942bce59529257cba42e60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 22:59:12 -0400 Subject: [PATCH 15/21] feat: add `pr-check` subcommand for rendering Terraform plans as Markdown comments --- cmd/oracle/cmd_pr_check_test.go | 256 ++++++++++++++++++++++++++++++++ cmd/oracle/main.go | 139 +++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 cmd/oracle/cmd_pr_check_test.go diff --git a/cmd/oracle/cmd_pr_check_test.go b/cmd/oracle/cmd_pr_check_test.go new file mode 100644 index 0000000..047cd06 --- /dev/null +++ b/cmd/oracle/cmd_pr_check_test.go @@ -0,0 +1,256 @@ +package main + +import ( + "bytes" + "context" + "errors" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "CloudOracle/internal/config" + "CloudOracle/internal/diff" +) + +// erroringSource satisfies diff.Source by always returning an error. +// Pricing engine reacts by marking every resource as Skipped: estimation +// failed, which is enough to exercise the orchestration paths in +// runPRCheck without standing up real pricing fixtures or an AWS SDK. +// We test the rest of the CostDiff machinery in internal/diff and +// internal/pricing with their own fixtures — repeating those tests at +// the cmd layer would only retest mocks. +type erroringSource struct{} + +func (erroringSource) GetProducts(_ context.Context, _ string, _ map[string]string) ([]string, error) { + return nil, errors.New("erroringSource: pricing not wired in tests") +} + +// withFakeSource swaps the package-level newPRCheckSource for one that +// returns the given diff.Source. The original factory is restored on +// test cleanup so tests stay independent. +func withFakeSource(t *testing.T, src diff.Source) { + t.Helper() + prev := newPRCheckSource + newPRCheckSource = func(_ context.Context) (diff.Source, error) { + return src, nil + } + t.Cleanup(func() { newPRCheckSource = prev }) +} + +// captureLogs replaces slog's default logger with one that writes to a +// buffer. The buffer is returned so individual tests can assert on log +// content (e.g. that the "no LLM provider configured" line appears or +// is suppressed). Logger is restored on test cleanup. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// emptyConfig returns a config with no LLM keys set, matching how the +// CI environment will look until 16.2 wires real secrets through. +func emptyConfig() config.Config { + return config.Config{} +} + +const ( + headerMarker = "## 💰 Cloud Cost Impact" + footerMarker = "" +) + +func TestPRCheck_HappyPath_Stdout(t *testing.T) { + withFakeSource(t, erroringSource{}) + + var stdout, stderr bytes.Buffer + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-no-llm", + } + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, headerMarker) { + t.Errorf("output missing header %q", headerMarker) + } + if !strings.Contains(out, footerMarker) { + t.Errorf("output missing footer %q", footerMarker) + } +} + +func TestPRCheck_HappyPath_OutputFile(t *testing.T) { + withFakeSource(t, erroringSource{}) + + tmp := filepath.Join(t.TempDir(), "comment.md") + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-output=" + tmp, + "-no-llm", + } + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if stdout.Len() != 0 { + t.Errorf("--output set but stdout still received %d bytes", stdout.Len()) + } + body, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("reading output file: %v", err) + } + bodyStr := string(body) + if !strings.Contains(bodyStr, headerMarker) { + t.Errorf("file missing header marker") + } + if !strings.Contains(bodyStr, footerMarker) { + t.Errorf("file missing footer marker") + } +} + +// TestPRCheck_NoLLMFlag exercises the --no-llm short-circuit. With the +// flag set, runPRCheck must not even attempt to construct an LLM +// provider — so the "no LLM provider configured" info-log produced by +// the auto-fallback path must NOT appear. Without the flag (and no +// keys configured) the same log line MUST appear, confirming we ran +// the LLM construction attempt. +func TestPRCheck_NoLLMFlag(t *testing.T) { + withFakeSource(t, erroringSource{}) + planArg := "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json") + + t.Run("with -no-llm: skips LLM provider construction", func(t *testing.T) { + logs := captureLogs(t) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{planArg, "-no-llm"}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if strings.Contains(logs.String(), "no LLM provider configured") { + t.Errorf("--no-llm should short-circuit before LLM construction; log:\n%s", logs.String()) + } + if strings.Contains(logs.String(), "rendering with LLM narrative") { + t.Errorf("--no-llm should not produce LLM-rendering log; log:\n%s", logs.String()) + } + }) + + t.Run("without -no-llm: attempts LLM and falls back", func(t *testing.T) { + logs := captureLogs(t) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{planArg}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "no LLM provider configured") { + t.Errorf("expected slog.Info 'no LLM provider configured' when no keys + no --no-llm; log:\n%s", logs.String()) + } + }) +} + +func TestPRCheck_PlanFileMissing(t *testing.T) { + withFakeSource(t, erroringSource{}) + + missing := filepath.Join(t.TempDir(), "does_not_exist.json") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-plan-file=" + missing, "-no-llm"}, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d for missing plan file, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--plan-file") { + t.Errorf("stderr should mention the failing flag; got: %s", stderr.String()) + } +} + +func TestPRCheck_PlanFileMissingFlag(t *testing.T) { + withFakeSource(t, erroringSource{}) + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-no-llm"}, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d when --plan-file is omitted, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--plan-file is required") { + t.Errorf("expected 'is required' message; got: %s", stderr.String()) + } +} + +func TestPRCheck_PlanFileEmpty(t *testing.T) { + withFakeSource(t, erroringSource{}) + + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_empty.json"), + "-no-llm", + } + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("empty plan should still produce a valid comment; got exit %d (stderr: %s)", code, stderr.String()) + } + out := stdout.String() + if !strings.Contains(out, headerMarker) { + t.Errorf("empty-plan output missing header marker") + } + if !strings.Contains(out, "No priceable resources") { + t.Errorf("empty-plan output should say 'No priceable resources'; got:\n%s", out) + } +} + +func TestPRCheck_OutputDirNotWritable(t *testing.T) { + withFakeSource(t, erroringSource{}) + + // A path under a non-existent parent directory: os.WriteFile will + // reject it with a "no such file or directory"-style error. We use + // t.TempDir() then join a nonexistent subdir — robust on Windows + // and POSIX without depending on hardcoded /nonexistent paths. + bad := filepath.Join(t.TempDir(), "does_not_exist_dir", "comment.md") + args := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-output=" + bad, + "-no-llm", + } + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOutputErr { + t.Errorf("expected exit %d for unwritable output path, got %d", exitPRCheckOutputErr, code) + } + if !strings.Contains(stderr.String(), "--output") { + t.Errorf("stderr should mention the --output flag on write failure; got: %s", stderr.String()) + } +} + +func TestPRCheck_HelpExitsZero(t *testing.T) { + // flag.ErrHelp is not a malformed invocation; --help is a deliberate + // user action and should exit 0 so CI-style scripts that do + // `oracle pr-check --help` to verify a binary works don't fail. + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-h"}, &stdout, &stderr) + if code != exitPRCheckOK { + t.Errorf("--help / -h should exit 0, got %d", code) + } +} + +func TestPRCheck_FlagParseFailureExitsOne(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), + []string{"-not-a-real-flag=true"}, &stdout, &stderr) + if code != exitPRCheckInputErr { + t.Errorf("unknown flag should exit %d, got %d", exitPRCheckInputErr, code) + } +} diff --git a/cmd/oracle/main.go b/cmd/oracle/main.go index 80dff49..39490cb 100644 --- a/cmd/oracle/main.go +++ b/cmd/oracle/main.go @@ -6,9 +6,12 @@ import ( "CloudOracle/internal/cloud" "CloudOracle/internal/config" "CloudOracle/internal/db" + "CloudOracle/internal/diff" + "CloudOracle/internal/iac" "CloudOracle/internal/llm" "CloudOracle/internal/logging" "CloudOracle/internal/migrations" + "CloudOracle/internal/pricing" "CloudOracle/internal/report" "CloudOracle/internal/shared" "context" @@ -20,6 +23,7 @@ import ( "os" "sort" "strings" + "time" ) func main() { @@ -39,6 +43,16 @@ func main() { logging.Setup(cfg.LogLevel, cfg.LogFormat) ctx := context.Background() + + // pr-check is a stateless plan→markdown transform — no database + // involved. The GitHub Action that wraps this binary in Hito 16.3 + // runs in environments where Postgres is not available, so we + // dispatch this subcommand before db.Connect to avoid a spurious + // connection failure. + if os.Args[1] == "pr-check" { + os.Exit(runPRCheck(ctx, cfg, os.Args[2:], os.Stdout, os.Stderr)) + } + pool, err := db.Connect(ctx, cfg.DB) if err != nil { slog.Error("failed to connect to database", "error", err) @@ -88,6 +102,8 @@ func printUsage() { fmt.Println(" oracle trend [--days N] - Show cost trends over time") fmt.Println(" oracle export --format=json|csv [--output file] - Export findings to JSON or CSV (stdout by default)") fmt.Println(" oracle serve [--port 8080] - Start the HTTP API for the dashboard") + fmt.Println(" oracle pr-check --plan-file=plan.json [--region=us-east-2] [--output=comment.md] [--no-llm]") + fmt.Println(" - Render a Terraform plan as a PR-comment Markdown") } func runSeed(ctx context.Context, pool *db.Pool, cfg config.Config, args []string) { @@ -422,6 +438,129 @@ func runExport(ctx context.Context, pool *db.Pool, args []string) { } } +// pr-check exit codes. Differentiated so the GitHub Action wrapper in +// Hito 16.3 can distinguish "the developer's plan is broken" (1) from +// "our pricing dependency failed" (2) from "we can't write the output" +// (3) — different remediations for each. The other oracle subcommands +// use exit 1 uniformly; pr-check is the first to be CI-targeted. +const ( + exitPRCheckOK = 0 + exitPRCheckInputErr = 1 + exitPRCheckPricingErr = 2 + exitPRCheckOutputErr = 3 +) + +// newPRCheckSource builds the pricing.Source used by `pr-check`. +// Wrapped as a package-level var so tests can swap it for a fake +// without spinning up the AWS SDK or hitting the network. Production +// builds get a 7-day disk cache wrapping a real AWS Pricing client; +// the cache is best-effort — a directory-creation failure logs WARN +// and falls back to the uncached client rather than aborting. +var newPRCheckSource = func(ctx context.Context) (diff.Source, error) { + client, err := pricing.NewClient(ctx) + if err != nil { + return nil, err + } + dir, dirErr := pricing.DefaultCacheDir() + if dirErr != nil || dir == "" { + slog.Warn("pricing cache disabled, using direct client", "error", dirErr) + return client, nil + } + cache, err := pricing.NewCache(client, dir, 7*24*time.Hour) + if err != nil { + slog.Warn("pricing cache disabled, using direct client", "error", err) + return client, nil + } + return cache, nil +} + +// runPRCheck is the orchestrator for the `pr-check` subcommand. It +// returns the process exit code instead of calling os.Exit directly so +// it is testable in-process; main() does the os.Exit at the dispatch +// site. stdout receives the rendered Markdown when --output is empty +// or "-"; stderr receives flag-parse and error messages. +func runPRCheck(ctx context.Context, cfg config.Config, args []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("pr-check", flag.ContinueOnError) + fs.SetOutput(stderr) + planFile := fs.String("plan-file", "", "path to `terraform show -json` output (required)") + region := fs.String("region", "us-east-2", "AWS region for pricing lookups") + output := fs.String("output", "", "file to write the Markdown to; empty or \"-\" means stdout") + noLLM := fs.Bool("no-llm", false, "force the templated narrative even if an LLM provider is configured") + + if err := fs.Parse(args); err != nil { + // flag.Parse already wrote a usage message to stderr. -h / --help + // is a deliberate user action, not a malformed invocation, so it + // exits 0; everything else is a flag-parse failure (exit 1). + if errors.Is(err, flag.ErrHelp) { + return exitPRCheckOK + } + return exitPRCheckInputErr + } + + if *planFile == "" { + fmt.Fprintln(stderr, "oracle pr-check: --plan-file is required") + return exitPRCheckInputErr + } + + plan, err := iac.ParsePlanFile(*planFile) + if err != nil { + fmt.Fprintf(stderr, "oracle pr-check: --plan-file %q: %v\n", *planFile, err) + return exitPRCheckInputErr + } + + source, err := newPRCheckSource(ctx) + if err != nil { + slog.Error("pr-check: pricing source unavailable", "error", err) + return exitPRCheckPricingErr + } + + costDiff, err := diff.Analyze(ctx, source, plan, *region) + if err != nil { + slog.Error("pr-check: diff analysis failed", "region", *region, "error", err) + return exitPRCheckPricingErr + } + + md := renderPRCheckMarkdown(ctx, cfg, costDiff, *noLLM) + + if *output == "" || *output == "-" { + if _, err := io.WriteString(stdout, md); err != nil { + slog.Error("pr-check: writing to stdout failed", "error", err) + return exitPRCheckOutputErr + } + return exitPRCheckOK + } + + if err := os.WriteFile(*output, []byte(md), 0o644); err != nil { + fmt.Fprintf(stderr, "oracle pr-check: --output %q: %v\n", *output, err) + return exitPRCheckOutputErr + } + slog.Info("pr-check: wrote markdown", "path", *output, "bytes", len(md)) + return exitPRCheckOK +} + +// renderPRCheckMarkdown picks between LLM-narrated and templated render. +// Splitting it from runPRCheck keeps the orchestrator's branching +// cyclomatic-low and makes the LLM-fallback path easy to reason about +// in isolation: LLM disabled (--no-llm), no provider keys configured, +// or provider construction error all converge on the same templated +// path. +func renderPRCheckMarkdown(ctx context.Context, cfg config.Config, d diff.CostDiff, noLLM bool) string { + if noLLM { + return diff.RenderMarkdown(d) + } + provider, err := llm.NewProvider(cfg.LLM) + if err != nil { + if errors.Is(err, llm.ErrNoProvider) { + slog.Info("pr-check: no LLM provider configured, using templated narrative") + } else { + slog.Warn("pr-check: LLM provider error, using templated narrative", "error", err) + } + return diff.RenderMarkdown(d) + } + slog.Info("pr-check: rendering with LLM narrative", "provider", provider.Name()) + return diff.RenderMarkdownWithLLM(ctx, d, provider) +} + func runServe(pool *db.Pool, args []string) { fs := flag.NewFlagSet("serve", flag.ExitOnError) port := fs.String("port", "8080", "Port to listen on") From 62dd731fa863f83396a8a96d5d4f8800c51e3981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 23:10:28 -0400 Subject: [PATCH 16/21] feat: implement GitHub REST API client for PR comment management --- internal/github/client.go | 84 +++++ internal/github/client_test.go | 114 +++++++ internal/github/comments.go | 310 +++++++++++++++++ internal/github/comments_test.go | 565 +++++++++++++++++++++++++++++++ internal/github/types.go | 29 ++ 5 files changed, 1102 insertions(+) create mode 100644 internal/github/client.go create mode 100644 internal/github/client_test.go create mode 100644 internal/github/comments.go create mode 100644 internal/github/comments_test.go create mode 100644 internal/github/types.go diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 0000000..4a3caca --- /dev/null +++ b/internal/github/client.go @@ -0,0 +1,84 @@ +package github + +import ( + "net/http" + "time" +) + +const ( + defaultBaseURL = "https://api.github.com" + defaultUserAgent = "CloudOracle/v2" + defaultTimeout = 30 * time.Second + + // apiVersion is the GitHub REST API version pinned via the + // X-GitHub-Api-Version header. GitHub recommends setting this + // explicitly so behaviour does not change under us when they ship + // a new API version. + apiVersion = "2022-11-28" + + // acceptHeader is the "modern" media type GitHub asks REST clients + // to send. application/vnd.github+json picks the latest stable + // representation for whatever endpoint we hit. + acceptHeader = "application/vnd.github+json" +) + +// Client is a thin GitHub REST API client scoped to the operations +// CloudOracle needs (PR comment list / post / update). It is not a +// general-purpose SDK and intentionally does not retry, throttle, or +// cache — those belong to the caller (the Hito 16.3 Action wrapper). +type Client struct { + token string + baseURL string + httpClient *http.Client + userAgent string +} + +// NewClient creates a Client with the production defaults: GitHub's +// public API host, a 30s HTTP timeout, and the "CloudOracle/v2" +// User-Agent. token must be a personal access token or a workflow +// GITHUB_TOKEN with the correct PR-write scope; passing an empty +// string is allowed (calls will fail with 401), so the caller can +// surface auth errors uniformly with a real-but-bad token. +func NewClient(token string) *Client { + return &Client{ + token: token, + baseURL: defaultBaseURL, + httpClient: &http.Client{Timeout: defaultTimeout}, + userAgent: defaultUserAgent, + } +} + +// NewClientWithConfig is the explicit constructor used by tests +// (httptest.NewServer URL via baseURL) and by callers targeting GitHub +// Enterprise Server. Empty strings on baseURL or userAgent fall back +// to the package defaults; httpClient may be nil to use the default +// 30s-timeout client. +func NewClientWithConfig(token, baseURL string, httpClient *http.Client, userAgent string) *Client { + if baseURL == "" { + baseURL = defaultBaseURL + } + if userAgent == "" { + userAgent = defaultUserAgent + } + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTimeout} + } + return &Client{ + token: token, + baseURL: baseURL, + httpClient: httpClient, + userAgent: userAgent, + } +} + +// setHeaders applies the standard set of GitHub REST headers (auth, +// accept, version, user-agent) to a request built by this package. +// Centralised so a single point owns the auth contract. +func (c *Client) setHeaders(req *http.Request) { + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + req.Header.Set("Accept", acceptHeader) + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", c.userAgent) +} diff --git a/internal/github/client_test.go b/internal/github/client_test.go new file mode 100644 index 0000000..4e181cf --- /dev/null +++ b/internal/github/client_test.go @@ -0,0 +1,114 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewClient_Defaults(t *testing.T) { + c := NewClient("token-abc") + if c.token != "token-abc" { + t.Errorf("token not stored: got %q", c.token) + } + if c.baseURL != defaultBaseURL { + t.Errorf("baseURL = %q, want %q", c.baseURL, defaultBaseURL) + } + if c.userAgent != defaultUserAgent { + t.Errorf("userAgent = %q, want %q", c.userAgent, defaultUserAgent) + } + if c.httpClient == nil { + t.Fatal("httpClient is nil") + } + if c.httpClient.Timeout != defaultTimeout { + t.Errorf("httpClient.Timeout = %v, want %v", c.httpClient.Timeout, defaultTimeout) + } +} + +func TestNewClientWithConfig_OverridesAndFallbacks(t *testing.T) { + custom := &http.Client{Timeout: 5 * time.Second} + c := NewClientWithConfig("tok", "https://ghe.example.com", custom, "Test/1.0") + if c.baseURL != "https://ghe.example.com" { + t.Errorf("baseURL not overridden") + } + if c.userAgent != "Test/1.0" { + t.Errorf("userAgent not overridden") + } + if c.httpClient != custom { + t.Errorf("httpClient not overridden") + } + + // Empty / nil arguments fall back to defaults. + def := NewClientWithConfig("tok", "", nil, "") + if def.baseURL != defaultBaseURL { + t.Errorf("empty baseURL did not fall back to default") + } + if def.userAgent != defaultUserAgent { + t.Errorf("empty userAgent did not fall back to default") + } + if def.httpClient == nil { + t.Errorf("nil httpClient did not fall back to default") + } +} + +// TestClient_AuthHeaderSet confirms every outbound request from this +// package includes the GitHub-required header bundle. We exercise the +// listComments path since it's the simplest GET; the other verbs share +// the same setHeaders helper, so a regression there would surface +// here too. +func TestClient_AuthHeaderSet(t *testing.T) { + var ( + gotAuth string + gotAccept string + gotVersion string + gotUA string + ) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + gotVersion = r.Header.Get("X-GitHub-Api-Version") + gotUA = r.Header.Get("User-Agent") + _ = json.NewEncoder(w).Encode([]Comment{}) + })) + defer srv.Close() + + c := NewClientWithConfig("the-token", srv.URL, srv.Client(), "") + if _, err := c.listComments(context.Background(), Repo{Owner: "o", Name: "r"}, 1); err != nil { + t.Fatalf("listComments: %v", err) + } + + if gotAuth != "Bearer the-token" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer the-token") + } + if gotAccept != acceptHeader { + t.Errorf("Accept = %q, want %q", gotAccept, acceptHeader) + } + if gotVersion != apiVersion { + t.Errorf("X-GitHub-Api-Version = %q, want %q", gotVersion, apiVersion) + } + if gotUA != defaultUserAgent { + t.Errorf("User-Agent = %q, want %q", gotUA, defaultUserAgent) + } +} + +// TestClient_AuthHeaderOmittedWhenTokenEmpty: an empty token must NOT +// produce a "Bearer " header (auth header absent), so a misconfigured +// caller fails with a clean GitHub 401 rather than an oddly-formed +// header that some servers reject earlier. +func TestClient_AuthHeaderOmittedWhenTokenEmpty(t *testing.T) { + var hadAuth bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hadAuth = r.Header["Authorization"] + w.WriteHeader(http.StatusUnauthorized) + })) + defer srv.Close() + + c := NewClientWithConfig("", srv.URL, srv.Client(), "") + _, _ = c.listComments(context.Background(), Repo{Owner: "o", Name: "r"}, 1) + if hadAuth { + t.Error("Authorization header set despite empty token") + } +} diff --git a/internal/github/comments.go b/internal/github/comments.go new file mode 100644 index 0000000..b4d9df8 --- /dev/null +++ b/internal/github/comments.go @@ -0,0 +1,310 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +const ( + // perPage is GitHub's max page size for list endpoints. Using the + // max minimises round-trips for repos with many comments. + perPage = 100 + + // maxPagesGitHub caps pagination at 5000 comments. Real PRs almost + // never exceed a few dozen; the cap exists so a buggy server or + // pagination loop cannot spin forever. Hitting the cap is logged + // as a warning so it is visible in the Action output. + maxPagesGitHub = 50 + + // maxBodyChars is a defensive cap below GitHub's documented + // ~65,536-char comment body limit. CloudOracle's rendered Markdown + // is typically <5KB, but the truncation guard prevents a 422 + // response if a future change makes the comment unexpectedly + // large. Truncation is best-effort: it may strip the trailing + // HTML marker, in which case the next push posts a fresh comment + // rather than updating the truncated one. + maxBodyChars = 60000 + + // truncationSuffix is appended to a body that was cropped to fit + // maxBodyChars. The visible "[truncated]" tells the reviewer the + // comment was incomplete; engineering can investigate via the + // Action logs. + truncationSuffix = "...\n[truncated]" +) + +// PostOrUpdateComment finds the most recent comment in the given PR +// whose body contains marker, and updates it; if no such comment +// exists, posts a new one. Returns the resulting comment ID, a +// "created" flag (true for a new comment, false for an update), and +// the first error encountered. +// +// The marker is a substring guaranteed to appear in CloudOracle- +// generated comments — typically an HTML comment like +// "" placed at the end of the rendered +// Markdown. The marker contract is symmetric: every CloudOracle +// comment must include it, and any comment containing it is +// considered "ours" for the purpose of update-vs-post. +// +// If multiple comments match the marker (rare; possible if a previous +// integration left duplicates or a manual paste happened), this +// function picks the one with the most recent UpdatedAt and emits a +// slog.Warn — it does not delete the others, since deletion is +// destructive and out of scope. +// +// Body length is capped at 60,000 characters; oversize bodies are +// truncated with a "...[truncated]" suffix and a slog.Warn. Note +// that truncation can remove the trailing marker, in which case the +// next pr-check posts a fresh comment instead of updating the +// truncated one. +// +// Errors are wrapped with stable prefixes the caller can match on: +// "github: authentication failed", "github: ... not found", +// "github: validation failed", "github: server error", and +// "github: request failed". No retries are performed — the caller +// (Action wrapper in Hito 16.3) owns retry policy. +// +// PRs and issues share the same numbering on GitHub; the "issues" +// comments endpoint serves both, which is why the parameter is named +// prNumber even though we hit /issues/{n}/comments. +func (c *Client) PostOrUpdateComment(ctx context.Context, repo Repo, prNumber int, body, marker string) (int64, bool, error) { + body = capBody(body) + + existing, err := c.listComments(ctx, repo, prNumber) + if err != nil { + return 0, false, err + } + + if match, ok := pickMostRecentMatch(existing, marker); ok { + if err := c.updateComment(ctx, repo, match.ID, body); err != nil { + return 0, false, err + } + return match.ID, false, nil + } + + id, err := c.postComment(ctx, repo, prNumber, body) + if err != nil { + return 0, false, err + } + return id, true, nil +} + +// pickMostRecentMatch scans comments for ones whose body contains the +// marker and returns the one with the latest UpdatedAt. If more than +// one matches, a warning is logged: a single match is the steady +// state, multiple matches indicate either a manual duplication or +// drift in the marker convention. +func pickMostRecentMatch(comments []Comment, marker string) (Comment, bool) { + var matches []Comment + for _, c := range comments { + if strings.Contains(c.Body, marker) { + matches = append(matches, c) + } + } + if len(matches) == 0 { + return Comment{}, false + } + winner := matches[0] + for _, m := range matches[1:] { + if m.UpdatedAt.After(winner.UpdatedAt) { + winner = m + } + } + if len(matches) > 1 { + slog.Warn("github: multiple comments match marker, updating most recent", + "matches", len(matches), + "winner_id", winner.ID, + "marker", marker) + } + return winner, true +} + +// capBody truncates a comment body that exceeds maxBodyChars, +// appending truncationSuffix so the reader knows the comment is +// incomplete. The combined length is exactly maxBodyChars (suffix +// included) — i.e. we crop the original to maxBodyChars - len(suffix) +// before appending. A warning is logged so operators can investigate. +func capBody(body string) string { + if len(body) <= maxBodyChars { + return body + } + keep := max(maxBodyChars-len(truncationSuffix), 0) + slog.Warn("github: comment body exceeds size cap, truncating", + "original_len", len(body), + "max", maxBodyChars, + "kept", keep) + return body[:keep] + truncationSuffix +} + +// listComments fetches every comment on the given issue/PR. GitHub +// uses cursor-style pagination with Link headers, but a per_page=100 +// request also reports the count via the JSON array length: a short +// final page (or an empty one) signals end-of-results. Parsing only +// the array length keeps the implementation simple and avoids a Link +// header parser. +// +// The loop is hard-capped at maxPagesGitHub iterations (5000 comments) +// so a buggy server cannot spin forever. Hitting the cap is logged +// and the comments collected so far are returned — the caller still +// gets useful data, and the warning is visible in the Action output. +func (c *Client) listComments(ctx context.Context, repo Repo, prNumber int) ([]Comment, error) { + var all []Comment + subject := fmt.Sprintf("repo %s/%s or PR #%d", repo.Owner, repo.Name, prNumber) + + for page := 1; page <= maxPagesGitHub; page++ { + batch, err := c.fetchCommentsPage(ctx, repo, prNumber, page, subject) + if err != nil { + return nil, err + } + all = append(all, batch...) + if len(batch) < perPage { + return all, nil + } + } + + slog.Warn("github: pagination cap hit, returning collected comments", + "max_pages", maxPagesGitHub, + "comments", len(all)) + return all, nil +} + +// fetchCommentsPage pulls a single page of issue comments. Split out +// of listComments so the request body close happens at function exit +// instead of being deferred inside a loop (which would leak HTTP +// connections until listComments itself returned). +func (c *Client) fetchCommentsPage(ctx context.Context, repo Repo, prNumber, page int, subject string) ([]Comment, error) { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments?per_page=%d&page=%d", + c.baseURL, repo.Owner, repo.Name, prNumber, perPage, page) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("github: request failed: %w", err) + } + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("github: request failed: reading body: %w", err) + } + if err := mapHTTPError(resp.StatusCode, body, subject); err != nil { + return nil, err + } + + var batch []Comment + if err := json.Unmarshal(body, &batch); err != nil { + return nil, fmt.Errorf("github: parsing list response: %w", err) + } + return batch, nil +} + +// postComment creates a new comment under the given issue/PR and +// returns its ID. GitHub responds with 201 Created and the full +// comment object on success; we only need the ID for downstream +// referencing. +func (c *Client) postComment(ctx context.Context, repo Repo, prNumber int, body string) (int64, error) { + url := fmt.Sprintf("%s/repos/%s/%s/issues/%d/comments", + c.baseURL, repo.Owner, repo.Name, prNumber) + subject := fmt.Sprintf("repo %s/%s or PR #%d", repo.Owner, repo.Name, prNumber) + + payload, err := json.Marshal(map[string]string{"body": body}) + if err != nil { + return 0, fmt.Errorf("github: marshalling comment: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return 0, fmt.Errorf("github: request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("github: request failed: reading body: %w", err) + } + if err := mapHTTPError(resp.StatusCode, respBody, subject); err != nil { + return 0, err + } + + var created Comment + if err := json.Unmarshal(respBody, &created); err != nil { + return 0, fmt.Errorf("github: parsing post response: %w", err) + } + return created.ID, nil +} + +// updateComment replaces the body of an existing comment. The endpoint +// is /repos/{owner}/{repo}/issues/comments/{commentID} — note that +// the PR number is not part of the path: GitHub identifies comments +// globally by ID once you have one. We still pass repo so a 404 can +// be reported with the same shape as listComments and postComment. +func (c *Client) updateComment(ctx context.Context, repo Repo, commentID int64, body string) error { + url := fmt.Sprintf("%s/repos/%s/%s/issues/comments/%d", + c.baseURL, repo.Owner, repo.Name, commentID) + subject := fmt.Sprintf("comment #%d on repo %s/%s", commentID, repo.Owner, repo.Name) + + payload, err := json.Marshal(map[string]string{"body": body}) + if err != nil { + return fmt.Errorf("github: marshalling comment update: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("github: request failed: %w", err) + } + req.Header.Set("Content-Type", "application/json") + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("github: request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("github: request failed: reading body: %w", err) + } + return mapHTTPError(resp.StatusCode, respBody, subject) +} + +// mapHTTPError translates a GitHub response status into a wrapped +// error with a stable prefix the caller can match on. 2xx returns +// nil. The mapping is deliberately coarse — callers that need the +// raw status or body can wrap a transport that captures them; for +// CloudOracle's purposes the prefix is what matters because the +// Action wrapper renders it to stderr. +func mapHTTPError(status int, body []byte, subject string) error { + if status >= 200 && status < 300 { + return nil + } + switch { + case status == http.StatusUnauthorized, status == http.StatusForbidden: + return fmt.Errorf("github: authentication failed (check GITHUB_TOKEN): %d %s", status, string(body)) + case status == http.StatusNotFound: + return fmt.Errorf("github: %s not found", subject) + case status == http.StatusUnprocessableEntity: + return fmt.Errorf("github: validation failed: %s", string(body)) + case status >= 500: + return fmt.Errorf("github: server error: %d %s", status, string(body)) + default: + return fmt.Errorf("github: unexpected status %d: %s", status, string(body)) + } +} diff --git a/internal/github/comments_test.go b/internal/github/comments_test.go new file mode 100644 index 0000000..256b064 --- /dev/null +++ b/internal/github/comments_test.go @@ -0,0 +1,565 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// captureLogs swaps slog's default logger for one that writes to a +// buffer, so tests can assert on warnings emitted by the silent +// fallbacks (multi-match, body truncation, pagination cap). Restored +// on test cleanup. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(prev) }) + return &buf +} + +// makeComment is a small constructor used to keep test fixtures terse. +func makeComment(id int64, body string, updated time.Time) Comment { + return Comment{ID: id, Body: body, UpdatedAt: updated} +} + +func mustParse(t *testing.T, ts string) time.Time { + t.Helper() + v, err := time.Parse(time.RFC3339, ts) + if err != nil { + t.Fatalf("bad timestamp %q: %v", ts, err) + } + return v +} + +// repo is the canonical test repo. The values are unimportant; the +// server validates them so a bug that swaps owner/name is loud. +var testRepo = Repo{Owner: "Cro22", Name: "CloudOracle"} + +// --- listComments --- + +func TestListComments_SinglePage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/repos/Cro22/CloudOracle/issues/42/comments") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + _ = json.NewEncoder(w).Encode([]Comment{ + makeComment(1, "first", mustParse(t, "2026-05-01T10:00:00Z")), + makeComment(2, "second", mustParse(t, "2026-05-02T10:00:00Z")), + }) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 42) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != 2 { + t.Errorf("got %d comments, want 2", len(got)) + } +} + +func TestListComments_TwoPages(t *testing.T) { + var pagesSeen []string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("page") + pagesSeen = append(pagesSeen, page) + switch page { + case "1": + _ = json.NewEncoder(w).Encode(makeBatch(100, 1)) + case "2": + _ = json.NewEncoder(w).Encode(makeBatch(50, 101)) + default: + t.Errorf("unexpected page request: %s", page) + } + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 1) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != 150 { + t.Errorf("got %d comments, want 150", len(got)) + } + if len(pagesSeen) != 2 || pagesSeen[0] != "1" || pagesSeen[1] != "2" { + t.Errorf("pages seen = %v, want [1 2]", pagesSeen) + } +} + +func TestListComments_PaginationCap(t *testing.T) { + logs := captureLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Always return a full page so the function never sees a + // short page (which would naturally terminate the loop). + _ = json.NewEncoder(w).Encode(makeBatch(100, 1)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + got, err := c.listComments(context.Background(), testRepo, 1) + if err != nil { + t.Fatalf("listComments: %v", err) + } + if len(got) != maxPagesGitHub*perPage { + t.Errorf("collected %d comments, want %d (cap)", len(got), maxPagesGitHub*perPage) + } + if !strings.Contains(logs.String(), "pagination cap hit") { + t.Errorf("expected pagination-cap warn; logs:\n%s", logs.String()) + } +} + +func TestListComments_404(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"Not Found"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 99) + if err == nil { + t.Fatal("expected error on 404") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want substring 'not found'", err) + } + if !strings.Contains(err.Error(), "PR #99") { + t.Errorf("error should reference the PR number; got %q", err) + } +} + +func TestListComments_401(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Bad credentials"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil { + t.Fatal("expected error on 401") + } + if !strings.Contains(err.Error(), "authentication failed") { + t.Errorf("error = %q, want substring 'authentication failed'", err) + } +} + +func TestListComments_403WrappedAsAuth(t *testing.T) { + // 403 (rate-limited / forbidden) shares the auth-error mapping + // because there's no useful user-facing distinction at the call + // site — both mean "GitHub rejected your token". + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"forbidden"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil || !strings.Contains(err.Error(), "authentication failed") { + t.Errorf("403 should map to authentication failed; got %v", err) + } +} + +func TestListComments_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() // closed before any request arrives — connection refused + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(context.Background(), testRepo, 1) + if err == nil { + t.Fatal("expected network error") + } + if !strings.Contains(err.Error(), "request failed") { + t.Errorf("error = %q, want substring 'request failed'", err) + } +} + +func TestListComments_ContextCancelled(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(2 * time.Second) + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.listComments(ctx, testRepo, 1) + if err == nil { + t.Fatal("expected context cancellation error") + } +} + +// --- postComment / updateComment --- + +func TestPostComment_Success(t *testing.T) { + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("method = %s, want POST", r.Method) + } + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Comment{ID: 12345, Body: "x", UpdatedAt: time.Now()}) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, err := c.postComment(context.Background(), testRepo, 7, "hello body") + if err != nil { + t.Fatalf("postComment: %v", err) + } + if id != 12345 { + t.Errorf("id = %d, want 12345", id) + } + if !strings.Contains(string(gotBody), `"body":"hello body"`) { + t.Errorf("server didn't see expected body; got %s", string(gotBody)) + } +} + +func TestPostComment_422(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation Failed","errors":[{"code":"missing"}]}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "validation failed") { + t.Errorf("422 should map to validation failed; got %v", err) + } +} + +func TestPostComment_500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`upstream broken`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "server error") { + t.Errorf("500 should map to server error; got %v", err) + } +} + +func TestPostComment_NetworkError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {})) + srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + _, err := c.postComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "request failed") { + t.Errorf("network error should map to request failed; got %v", err) + } +} + +func TestUpdateComment_Success(t *testing.T) { + var gotMethod, gotPath string + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotBody, _ = io.ReadAll(r.Body) + _ = json.NewEncoder(w).Encode(Comment{ID: 12345, Body: "updated"}) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + if err := c.updateComment(context.Background(), testRepo, 12345, "updated body"); err != nil { + t.Fatalf("updateComment: %v", err) + } + if gotMethod != http.MethodPatch { + t.Errorf("method = %s, want PATCH", gotMethod) + } + if !strings.HasSuffix(gotPath, "/issues/comments/12345") { + t.Errorf("path = %s, want suffix '/issues/comments/12345'", gotPath) + } + if !strings.Contains(string(gotBody), `"body":"updated body"`) { + t.Errorf("server didn't see expected body; got %s", string(gotBody)) + } +} + +func TestUpdateComment_422(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"validation"}`)) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + err := c.updateComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "validation failed") { + t.Errorf("422 should map to validation failed; got %v", err) + } +} + +func TestUpdateComment_500(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + err := c.updateComment(context.Background(), testRepo, 1, "body") + if err == nil || !strings.Contains(err.Error(), "server error") { + t.Errorf("502 should map to server error; got %v", err) + } +} + +// --- PostOrUpdateComment integration --- + +const testMarker = "" + +// scriptedServer is a tiny mux that lets each test wire its own +// list / post / update behaviour. Every request increments calls +// counters so tests can assert on which verbs were exercised. +type scriptedServer struct { + t *testing.T + listComments func(page string) ([]Comment, int) + postBody string + postID int64 + postStatus int + updateBody string + updateID int64 + updateStatus int + calls struct { + list, post, update int + } +} + +func (s *scriptedServer) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/issues/") && strings.HasSuffix(r.URL.Path, "/comments"): + s.calls.list++ + batch, status := s.listComments(r.URL.Query().Get("page")) + if status != 0 { + w.WriteHeader(status) + return + } + _ = json.NewEncoder(w).Encode(batch) + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/comments"): + s.calls.post++ + body, _ := io.ReadAll(r.Body) + s.postBody = string(body) + if s.postStatus != 0 { + w.WriteHeader(s.postStatus) + _, _ = w.Write([]byte(`{"message":"forced failure"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(Comment{ID: s.postID, Body: "x"}) + case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/issues/comments/"): + s.calls.update++ + body, _ := io.ReadAll(r.Body) + s.updateBody = string(body) + parts := strings.Split(r.URL.Path, "/") + s.updateID, _ = parseInt64(parts[len(parts)-1]) + if s.updateStatus != 0 { + w.WriteHeader(s.updateStatus) + return + } + _ = json.NewEncoder(w).Encode(Comment{ID: s.updateID, Body: "updated"}) + default: + s.t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + } +} + +func TestPostOrUpdateComment_Update(t *testing.T) { + existing := []Comment{ + makeComment(101, "## 💰 Cloud Cost Impact\nbody A\n"+testMarker, mustParse(t, "2026-05-01T10:00:00Z")), + makeComment(102, "an unrelated review comment", mustParse(t, "2026-05-02T10:00:00Z")), + } + scr := &scriptedServer{ + t: t, + listComments: func(page string) ([]Comment, int) { + if page == "1" { + return existing, 0 + } + return nil, 0 + }, + updateID: 101, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "new body "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if created { + t.Errorf("expected created=false (update path)") + } + if id != 101 { + t.Errorf("id = %d, want 101", id) + } + if scr.calls.update != 1 { + t.Errorf("expected 1 update call, got %d", scr.calls.update) + } + if scr.calls.post != 0 { + t.Errorf("expected 0 post calls on update path, got %d", scr.calls.post) + } + if scr.updateID != 101 { + t.Errorf("PATCHed wrong comment ID: %d", scr.updateID) + } + if !strings.Contains(scr.updateBody, "new body") { + t.Errorf("update body did not contain new content; got %s", scr.updateBody) + } +} + +func TestPostOrUpdateComment_PostNew(t *testing.T) { + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postID: 999, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "fresh "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if !created { + t.Errorf("expected created=true (new comment path)") + } + if id != 999 { + t.Errorf("id = %d, want 999", id) + } + if scr.calls.post != 1 { + t.Errorf("expected 1 post call, got %d", scr.calls.post) + } + if scr.calls.update != 0 { + t.Errorf("expected 0 update calls on post path, got %d", scr.calls.update) + } +} + +func TestPostOrUpdateComment_MultipleMatches(t *testing.T) { + logs := captureLogs(t) + existing := []Comment{ + makeComment(10, "old "+testMarker, mustParse(t, "2026-04-01T10:00:00Z")), + makeComment(20, "newest "+testMarker, mustParse(t, "2026-05-10T10:00:00Z")), + makeComment(30, "middle "+testMarker, mustParse(t, "2026-04-15T10:00:00Z")), + } + scr := &scriptedServer{ + t: t, + listComments: func(page string) ([]Comment, int) { + if page == "1" { + return existing, 0 + } + return nil, 0 + }, + updateID: 20, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 5, "newer "+testMarker, testMarker) + if err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + if created { + t.Errorf("expected update path, got created=true") + } + if id != 20 { + t.Errorf("id = %d, want 20 (most recent updated_at)", id) + } + if scr.updateID != 20 { + t.Errorf("PATCHed comment ID = %d, want 20", scr.updateID) + } + if !strings.Contains(logs.String(), "multiple comments match marker") { + t.Errorf("expected multi-match warn; logs:\n%s", logs.String()) + } +} + +func TestPostOrUpdateComment_BodyTooLong(t *testing.T) { + logs := captureLogs(t) + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postID: 1, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + bigBody := strings.Repeat("x", 70000) + testMarker + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + if _, _, err := c.PostOrUpdateComment(context.Background(), testRepo, 1, bigBody, testMarker); err != nil { + t.Fatalf("PostOrUpdateComment: %v", err) + } + + if !strings.Contains(logs.String(), "exceeds size cap") { + t.Errorf("expected truncation warn; logs:\n%s", logs.String()) + } + // The server should have observed a body shorter than the original. + if len(scr.postBody) >= 70000 { + t.Errorf("post body not truncated: len=%d", len(scr.postBody)) + } + if !strings.Contains(scr.postBody, "[truncated]") { + t.Errorf("post body missing truncation suffix; got tail: %q", + scr.postBody[max(0, len(scr.postBody)-200):]) + } +} + +func TestPostOrUpdateComment_PostFails(t *testing.T) { + scr := &scriptedServer{ + t: t, + listComments: func(_ string) ([]Comment, int) { return []Comment{}, 0 }, + postStatus: http.StatusUnprocessableEntity, + } + srv := httptest.NewServer(scr.handler()) + defer srv.Close() + + c := NewClientWithConfig("tok", srv.URL, srv.Client(), "") + id, created, err := c.PostOrUpdateComment(context.Background(), testRepo, 1, "body "+testMarker, testMarker) + if err == nil { + t.Fatal("expected post failure to surface") + } + if id != 0 { + t.Errorf("expected id=0 on failure, got %d", id) + } + if created { + t.Errorf("expected created=false on failure") + } + if !strings.Contains(err.Error(), "validation failed") { + t.Errorf("error = %v, want validation failed", err) + } +} + +// --- helpers --- + +func makeBatch(n, startID int) []Comment { + out := make([]Comment, n) + for i := range n { + out[i] = Comment{ID: int64(startID + i), Body: "x"} + } + return out +} + +func parseInt64(s string) (int64, error) { + var n int64 + _, err := fmt.Sscan(s, &n) + return n, err +} diff --git a/internal/github/types.go b/internal/github/types.go new file mode 100644 index 0000000..014d999 --- /dev/null +++ b/internal/github/types.go @@ -0,0 +1,29 @@ +// Package github is a thin GitHub REST API client scoped to the +// operations CloudOracle's PR-comment integration needs: listing, +// posting, and updating issue comments. It is deliberately not a full +// SDK — webhooks, branches, releases, reactions, and review comments +// are out of scope. +// +// The package only deserialises the fields it actually uses; unknown +// JSON keys in GitHub's responses are ignored. This keeps the package +// resilient to API additions without forcing dependency churn. +package github + +import "time" + +// Repo identifies a GitHub repository by its owner login and repo +// name. Both are required by every endpoint this package calls. +type Repo struct { + Owner string // e.g. "Cro22" + Name string // e.g. "CloudOracle" +} + +// Comment is a minimal projection of GitHub's issue/PR comment payload. +// We only deserialise the fields the marker-matching and update flow +// need; GitHub's full comment object is much larger and would tie us +// to fields we don't read. +type Comment struct { + ID int64 `json:"id"` + Body string `json:"body"` + UpdatedAt time.Time `json:"updated_at"` +} From d5fcaafc453cae2f0d10cd0a5fffb80a5ffe35b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 23:22:46 -0400 Subject: [PATCH 17/21] feat: add support for posting Terraform plan comments to GitHub via PR-check --- cmd/oracle/cmd_pr_check_test.go | 377 ++++++++++++++++++++++++++++++++ cmd/oracle/main.go | 124 ++++++++++- 2 files changed, 496 insertions(+), 5 deletions(-) diff --git a/cmd/oracle/cmd_pr_check_test.go b/cmd/oracle/cmd_pr_check_test.go index 047cd06..dbc8603 100644 --- a/cmd/oracle/cmd_pr_check_test.go +++ b/cmd/oracle/cmd_pr_check_test.go @@ -12,6 +12,7 @@ import ( "CloudOracle/internal/config" "CloudOracle/internal/diff" + "CloudOracle/internal/github" ) // erroringSource satisfies diff.Source by always returning an error. @@ -254,3 +255,379 @@ func TestPRCheck_FlagParseFailureExitsOne(t *testing.T) { t.Errorf("unknown flag should exit %d, got %d", exitPRCheckInputErr, code) } } + +// --- --post flag tests --- + +// postCall captures one PostOrUpdateComment invocation so a test can +// assert on what runPRCheck handed to the github layer. +type postCall struct { + repo github.Repo + pr int + body string + marker string +} + +// recordingGithubPoster is the test double for githubPoster: it logs +// every call and lets each test program a return value or error. +type recordingGithubPoster struct { + calls []postCall + id int64 + created bool + err error +} + +func (r *recordingGithubPoster) PostOrUpdateComment(_ context.Context, repo github.Repo, prNumber int, body, marker string) (int64, bool, error) { + r.calls = append(r.calls, postCall{repo: repo, pr: prNumber, body: body, marker: marker}) + if r.err != nil { + return 0, false, r.err + } + id := r.id + if id == 0 { + id = 12345 + } + return id, r.created, nil +} + +// withFakeGithubClient swaps the package-level newPRCheckGithubClient +// for one that returns the supplied poster, capturing the token the +// factory was called with so tests can assert on env-derived values. +// The original factory is restored on test cleanup. +func withFakeGithubClient(t *testing.T, poster githubPoster) *string { + t.Helper() + var capturedToken string + prev := newPRCheckGithubClient + newPRCheckGithubClient = func(token string) githubPoster { + capturedToken = token + return poster + } + t.Cleanup(func() { newPRCheckGithubClient = prev }) + return &capturedToken +} + +// commonArgs returns the boilerplate flags shared by post-flag tests. +func commonArgs(extra ...string) []string { + base := []string{ + "-plan-file=" + filepath.Join("..", "..", "internal", "iac", "testdata", "plan_simple_create.json"), + "-no-llm", + } + return append(base, extra...) +} + +func TestPRCheck_PostFlag_HappyPath(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 555, created: true} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11", "-token=test-token") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if len(rec.calls) != 1 { + t.Fatalf("expected 1 post call, got %d", len(rec.calls)) + } + c := rec.calls[0] + if c.repo != (github.Repo{Owner: "Cro22", Name: "CloudOracle"}) { + t.Errorf("repo = %+v, want Cro22/CloudOracle", c.repo) + } + if c.pr != 11 { + t.Errorf("pr = %d, want 11", c.pr) + } + if !strings.Contains(c.body, footerMarker) { + t.Errorf("post body missing footer marker") + } + if c.marker != defaultMarker { + t.Errorf("marker = %q, want default %q", c.marker, defaultMarker) + } + // stdout still received the markdown — --post is additive, not exclusive. + if !strings.Contains(stdout.String(), headerMarker) { + t.Errorf("stdout should still contain markdown when --post is set") + } +} + +func TestPRCheck_PostFlag_TokenFromEnv(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + captured := withFakeGithubClient(t, rec) + + t.Setenv("GITHUB_TOKEN", "env-token") + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11") // no -token + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if *captured != "env-token" { + t.Errorf("token captured = %q, want %q (from env)", *captured, "env-token") + } +} + +func TestPRCheck_PostFlag_NoToken(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + t.Setenv("GITHUB_TOKEN", "") // make sure the env doesn't satisfy the requirement + args := commonArgs("-post", "-repo=Cro22/CloudOracle", "-pr=11") + + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("expected exit %d (input error), got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "GITHUB_TOKEN") { + t.Errorf("stderr should mention GITHUB_TOKEN; got: %s", stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github should not be called when token is missing") + } +} + +func TestPRCheck_PostFlag_BadRepoFormat(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=invalid-no-slash", "-pr=11", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("bad repo format should exit %d, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "owner/name") { + t.Errorf("stderr should mention 'owner/name'; got: %s", stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github should not be called on bad repo format") + } +} + +func TestPRCheck_PostFlag_NegativePR(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=-1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckInputErr { + t.Errorf("negative PR should exit %d, got %d", exitPRCheckInputErr, code) + } + if !strings.Contains(stderr.String(), "--pr") { + t.Errorf("stderr should mention --pr; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_AuthError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: authentication failed (check GITHUB_TOKEN): 401 Bad credentials"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("auth error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "authentication") { + t.Errorf("stderr should mention 'authentication'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_NotFoundError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: repo o/n or PR #1 not found"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("not-found error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "not found") { + t.Errorf("stderr should mention 'not found'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_ValidationError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: validation failed: body too long"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("validation error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "validation") { + t.Errorf("stderr should mention 'validation'; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_GenericError(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{ + err: errors.New("github: something unexpected happened"), + } + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckGitHubErr { + t.Errorf("generic github error should exit %d, got %d", exitPRCheckGitHubErr, code) + } + if !strings.Contains(stderr.String(), "something unexpected happened") { + t.Errorf("stderr should contain the underlying error message; got: %s", stderr.String()) + } +} + +func TestPRCheck_PostFlag_LogsCreateVsUpdate(t *testing.T) { + t.Run("created=true logs 'created'", func(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 100, created: true} + withFakeGithubClient(t, rec) + logs := captureLogs(t) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "created") { + t.Errorf("logs should contain 'created' for created=true; got:\n%s", logs.String()) + } + if strings.Contains(logs.String(), "comment updated") { + t.Errorf("logs should NOT say 'updated' when created=true; got:\n%s", logs.String()) + } + }) + + t.Run("created=false logs 'updated'", func(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 100, created: false} + withFakeGithubClient(t, rec) + logs := captureLogs(t) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if !strings.Contains(logs.String(), "updated") { + t.Errorf("logs should contain 'updated' for created=false; got:\n%s", logs.String()) + } + }) +} + +func TestPRCheck_NoPostFlag_DoesNotCallGithub(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs() // no --post + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + if len(rec.calls) != 0 { + t.Errorf("github must not be called without --post; saw %d calls", len(rec.calls)) + } +} + +func TestPRCheck_PostFlag_WritesOutputFile(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{id: 7} + withFakeGithubClient(t, rec) + + tmp := filepath.Join(t.TempDir(), "comment.md") + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t", "-output="+tmp) + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d (stderr: %s)", code, stderr.String()) + } + body, err := os.ReadFile(tmp) + if err != nil { + t.Fatalf("output file missing: %v", err) + } + if !strings.Contains(string(body), headerMarker) { + t.Errorf("output file does not contain markdown header") + } + if len(rec.calls) != 1 { + t.Errorf("expected 1 post call alongside the file write, got %d", len(rec.calls)) + } +} + +func TestPRCheck_PostFlag_CustomMarker(t *testing.T) { + withFakeSource(t, erroringSource{}) + rec := &recordingGithubPoster{} + withFakeGithubClient(t, rec) + + args := commonArgs("-post", "-repo=o/n", "-pr=1", "-token=t", "-marker=v2-prefix") + var stdout, stderr bytes.Buffer + code := runPRCheck(context.Background(), emptyConfig(), args, &stdout, &stderr) + + if code != exitPRCheckOK { + t.Fatalf("expected exit 0, got %d", code) + } + if len(rec.calls) != 1 { + t.Fatalf("expected 1 post call, got %d", len(rec.calls)) + } + if rec.calls[0].marker != "v2-prefix" { + t.Errorf("marker = %q, want %q", rec.calls[0].marker, "v2-prefix") + } +} + +// TestParseRepo covers the small helper directly. All paths through +// parseRepo are exercised indirectly by the post-flag tests above; the +// dedicated table here makes regressions in the validation rule +// (e.g. accepting "owner/" or "/name") surface with a sharper failure. +func TestParseRepo(t *testing.T) { + cases := []struct { + in string + wantErr bool + wantRepo github.Repo + }{ + {"Cro22/CloudOracle", false, github.Repo{Owner: "Cro22", Name: "CloudOracle"}}, + {"a/b", false, github.Repo{Owner: "a", Name: "b"}}, + {"", true, github.Repo{}}, + {"no-slash", true, github.Repo{}}, + {"/missing-owner", true, github.Repo{}}, + {"missing-name/", true, github.Repo{}}, + {"a/b/c", false, github.Repo{Owner: "a", Name: "b/c"}}, // SplitN keeps trailing '/c' as Name; documented behaviour + } + for _, c := range cases { + got, err := parseRepo(c.in) + if (err != nil) != c.wantErr { + t.Errorf("parseRepo(%q) err = %v, wantErr %v", c.in, err, c.wantErr) + continue + } + if !c.wantErr && got != c.wantRepo { + t.Errorf("parseRepo(%q) = %+v, want %+v", c.in, got, c.wantRepo) + } + } +} diff --git a/cmd/oracle/main.go b/cmd/oracle/main.go index 39490cb..3b70e3d 100644 --- a/cmd/oracle/main.go +++ b/cmd/oracle/main.go @@ -7,6 +7,7 @@ import ( "CloudOracle/internal/config" "CloudOracle/internal/db" "CloudOracle/internal/diff" + "CloudOracle/internal/github" "CloudOracle/internal/iac" "CloudOracle/internal/llm" "CloudOracle/internal/logging" @@ -103,7 +104,9 @@ func printUsage() { fmt.Println(" oracle export --format=json|csv [--output file] - Export findings to JSON or CSV (stdout by default)") fmt.Println(" oracle serve [--port 8080] - Start the HTTP API for the dashboard") fmt.Println(" oracle pr-check --plan-file=plan.json [--region=us-east-2] [--output=comment.md] [--no-llm]") - fmt.Println(" - Render a Terraform plan as a PR-comment Markdown") + fmt.Println(" [--post --repo=owner/name --pr=N [--token=TOK] [--marker=cloudoracle-pr-v1]]") + fmt.Println(" - Render a Terraform plan as a PR-comment Markdown,") + fmt.Println(" optionally posting/updating it on GitHub") } func runSeed(ctx context.Context, pool *db.Pool, cfg config.Config, args []string) { @@ -448,8 +451,31 @@ const ( exitPRCheckInputErr = 1 exitPRCheckPricingErr = 2 exitPRCheckOutputErr = 3 + exitPRCheckGitHubErr = 4 ) +// defaultMarker matches the HTML marker that diff.RenderMarkdown emits at +// the bottom of every CloudOracle PR comment. The --marker flag exists so +// users can override (e.g. for a future v2 marker without breaking +// existing v1 comments) but the default is the canonical one. +const defaultMarker = "cloudoracle-pr-v1" + +// githubPoster is the subset of *github.Client behaviour runPRCheck +// actually exercises. Defining it here (rather than exporting one from +// internal/github) keeps the test fake's surface tiny: a single method +// instead of the whole Client. +type githubPoster interface { + PostOrUpdateComment(ctx context.Context, repo github.Repo, prNumber int, body, marker string) (int64, bool, error) +} + +// newPRCheckGithubClient is the factory used by runPRCheck to obtain a +// githubPoster. Wrapped as a package-level var so tests can swap it for +// a recording fake. Production wraps github.NewClient — *github.Client +// already satisfies githubPoster by structural typing. +var newPRCheckGithubClient = func(token string) githubPoster { + return github.NewClient(token) +} + // newPRCheckSource builds the pricing.Source used by `pr-check`. // Wrapped as a package-level var so tests can swap it for a fake // without spinning up the AWS SDK or hitting the network. Production @@ -486,6 +512,11 @@ func runPRCheck(ctx context.Context, cfg config.Config, args []string, stdout, s region := fs.String("region", "us-east-2", "AWS region for pricing lookups") output := fs.String("output", "", "file to write the Markdown to; empty or \"-\" means stdout") noLLM := fs.Bool("no-llm", false, "force the templated narrative even if an LLM provider is configured") + post := fs.Bool("post", false, "post the rendered Markdown as a PR comment (upserts via marker)") + repoFlag := fs.String("repo", "", "target repo in `owner/name` form (required when --post is set)") + prNumber := fs.Int("pr", 0, "target PR number (required when --post is set)") + tokenFlag := fs.String("token", "", "GitHub token; falls back to the GITHUB_TOKEN env var when empty") + marker := fs.String("marker", defaultMarker, "HTML marker substring used to find the existing CloudOracle comment for upsert") if err := fs.Parse(args); err != nil { // flag.Parse already wrote a usage message to stderr. -h / --help @@ -522,22 +553,105 @@ func runPRCheck(ctx context.Context, cfg config.Config, args []string, stdout, s md := renderPRCheckMarkdown(ctx, cfg, costDiff, *noLLM) + // Output first (stdout when unset or "-"; file otherwise). --output + // and --post are independent: a CI run that sets both gets the file + // for artefact upload AND the comment posted. if *output == "" || *output == "-" { if _, err := io.WriteString(stdout, md); err != nil { slog.Error("pr-check: writing to stdout failed", "error", err) return exitPRCheckOutputErr } + } else { + if err := os.WriteFile(*output, []byte(md), 0o644); err != nil { + fmt.Fprintf(stderr, "oracle pr-check: --output %q: %v\n", *output, err) + return exitPRCheckOutputErr + } + slog.Info("pr-check: wrote markdown", "path", *output, "bytes", len(md)) + } + + if !*post { return exitPRCheckOK } + return runPRCheckPost(ctx, *repoFlag, *prNumber, *tokenFlag, md, *marker, stderr) +} + +// runPRCheckPost validates the post-related flags and dispatches to the +// configured githubPoster. Split from runPRCheck so the post path's +// flag-validation, token resolution, and error mapping have one home — +// the orchestrator stays linear and the unit tests can target each +// failure mode without rebuilding the analyze/render plumbing. +func runPRCheckPost(ctx context.Context, repoFlag string, prNumber int, tokenFlag, body, marker string, stderr io.Writer) int { + if prNumber <= 0 { + fmt.Fprintln(stderr, "oracle pr-check: --pr must be a positive integer when --post is set") + return exitPRCheckInputErr + } + repo, err := parseRepo(repoFlag) + if err != nil { + fmt.Fprintf(stderr, "oracle pr-check: %v\n", err) + return exitPRCheckInputErr + } + token := tokenFlag + if token == "" { + token = os.Getenv("GITHUB_TOKEN") + } + if token == "" { + fmt.Fprintln(stderr, "oracle pr-check: --token or GITHUB_TOKEN env required when --post is set") + return exitPRCheckInputErr + } + + client := newPRCheckGithubClient(token) + id, created, err := client.PostOrUpdateComment(ctx, repo, prNumber, body, marker) + if err != nil { + return classifyGithubError(err, stderr) + } - if err := os.WriteFile(*output, []byte(md), 0o644); err != nil { - fmt.Fprintf(stderr, "oracle pr-check: --output %q: %v\n", *output, err) - return exitPRCheckOutputErr + action := "updated" + if created { + action = "created" } - slog.Info("pr-check: wrote markdown", "path", *output, "bytes", len(md)) + slog.Info("pr-check: github comment "+action, + "comment_id", id, + "repo", repoFlag, + "pr", prNumber, + "marker", marker) return exitPRCheckOK } +// parseRepo accepts the "owner/name" form used by GitHub URLs and +// returns a github.Repo. Both halves must be non-empty; we use SplitN +// with n=2 so a name containing a slash (which GitHub forbids anyway) +// would be caught by the empty-half check rather than silently keeping +// the trailing portion. +func parseRepo(s string) (github.Repo, error) { + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return github.Repo{}, fmt.Errorf("--repo must be in 'owner/name' format, got %q", s) + } + return github.Repo{Owner: parts[0], Name: parts[1]}, nil +} + +// classifyGithubError maps an error string from internal/github to a +// user-facing stderr line and the exit-4 code. Matching is by stable +// substring rather than wrapped sentinel errors because internal/github +// uses fmt.Errorf with prefix conventions (documented at the +// PostOrUpdateComment godoc); converting to typed errors would couple +// the two packages tighter than necessary. +func classifyGithubError(err error, stderr io.Writer) int { + msg := err.Error() + switch { + case strings.Contains(msg, "authentication failed"): + fmt.Fprintln(stderr, "oracle pr-check: github post failed: authentication; check token permissions") + case strings.Contains(msg, "not found"): + fmt.Fprintln(stderr, "oracle pr-check: github post failed: repo or PR not found") + case strings.Contains(msg, "validation failed"): + fmt.Fprintf(stderr, "oracle pr-check: github post failed: validation: %s\n", + strings.TrimPrefix(msg, "github: validation failed: ")) + default: + fmt.Fprintf(stderr, "oracle pr-check: github post failed: %s\n", msg) + } + return exitPRCheckGitHubErr +} + // renderPRCheckMarkdown picks between LLM-narrated and templated render. // Splitting it from runPRCheck keeps the orchestrator's branching // cyclomatic-low and makes the LLM-fallback path easy to reason about From a0ea88deebc2580c76c247f5c43658bdc4e6fa38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Fri, 8 May 2026 23:55:28 -0400 Subject: [PATCH 18/21] feat: add CloudOracle GitHub Action for Terraform cost analysis and update README with usage examples --- .dockerignore | 41 ++++- .github/examples/README.md | 107 ++++++++++++ .github/examples/terraform-plan-no-llm.yml | 42 +++++ .github/examples/terraform-plan.yml | 64 +++++++ Dockerfile.action | 58 +++++++ README.md | 186 ++++++++++++++++++++- action.yml | 36 ++++ entrypoint.sh | 62 +++++++ 8 files changed, 588 insertions(+), 8 deletions(-) create mode 100644 .github/examples/README.md create mode 100644 .github/examples/terraform-plan-no-llm.yml create mode 100644 .github/examples/terraform-plan.yml create mode 100644 Dockerfile.action create mode 100644 action.yml create mode 100644 entrypoint.sh diff --git a/.dockerignore b/.dockerignore index 605eb08..5ef06d7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,24 +1,59 @@ +# Shared .dockerignore — applies to BOTH the v1 dashboard build +# (Dockerfile) and the v2 Action build (Dockerfile.action). +# +# Rule of thumb: only list paths neither image needs in its build +# context. If a path is needed by exactly one of the two, leave it +# in and let the COPY in the other Dockerfile decide. Splitting this +# file per-Dockerfile (e.g. via Dockerfile..dockerignore) is +# avoided so we have one source of truth. + +# --- Local dev / VCS / IDE noise --- .git/ +.github/ .idea/ .claude/ +# --- Build artefacts that should never enter an image --- *.exe *.test *.out *.pem +oracle +oracle.exe +probe-pricing +probe-pricing.exe +cloudoracle +cloudoracle.exe +# --- Secrets --- .env .env.* +# --- v1 dashboard frontend artefacts --- +# (rebuilt inside the multi-stage v1 Dockerfile; ignored from the host) web/node_modules/ web/dist/ internal/api/dist/ +# --- Test fixtures and tests (not needed at runtime) --- +**/testdata/ +**/*_test.go + +# --- Other cmd/ binaries that are dev-only, not for shipping --- +cmd/probe-pricing/ +cmd/checkpoint*/ +cmd/narrative-preview/ + +# --- Project meta / sample artefacts --- +checkpoint/ +docker-compose.yml +example.png example_report.pdf +examplepdf.png cloudoracle-report.pdf -cloudoracle -cloudoracle.exe +README.md +# --- Docker plumbing --- Dockerfile +Dockerfile.action .dockerignore -README.md diff --git a/.github/examples/README.md b/.github/examples/README.md new file mode 100644 index 0000000..768d3f1 --- /dev/null +++ b/.github/examples/README.md @@ -0,0 +1,107 @@ +# CloudOracle Action — Workflow Examples + +These two YAML files are runnable references for wiring the CloudOracle +Action into a Terraform repository. Pick whichever matches your auth +posture and LLM appetite, copy it under `.github/workflows/` in your +target repo, and adjust the paths and IAM ARN. + +## Which example do I want? + +| File | AWS auth | LLM narrative | Best for | +|------|----------|---------------|----------| +| [`terraform-plan.yml`](terraform-plan.yml) | OIDC (recommended) | Yes (Anthropic / Gemini / OpenAI) | Production setups, security-conscious orgs | +| [`terraform-plan-no-llm.yml`](terraform-plan-no-llm.yml) | Static access keys | No (templated text) | Quick start, no LLM procurement, air-gapped CI | + +Both produce a single PR comment that updates in place across pushes +(via the `cloudoracle-pr-v1` HTML marker), so the conversation thread +stays clean. + +## Required permissions + +The workflow needs the following at minimum: + +```yaml +permissions: + pull-requests: write # to post/update the comment + contents: read # to checkout the repo + id-token: write # ONLY when using OIDC (omit for static keys) +``` + +GitHub's default workflow permissions vary by org policy; declaring +them explicitly makes the workflow portable. + +## AWS IAM setup for OIDC + +The OIDC example assumes a role trust policy of the form: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" + }, + "StringLike": { + "token.actions.githubusercontent.com:sub": "repo:YOUR_ORG/YOUR_REPO:pull_request" + } + } + }] +} +``` + +The role only needs `pricing:GetProducts` on `*` — CloudOracle does not +read or modify any AWS resources beyond Pricing API metadata: + +```json +{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": "pricing:GetProducts", + "Resource": "*" + }] +} +``` + +GitHub's OIDC setup guide: +https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services + +## LLM key as a secret + +Set one of the following as a repository or organisation secret: + +- `ANTHROPIC_API_KEY` (Claude — used by default in v2) +- `GEMINI_API_KEY` +- `OPENAI_API_KEY` + +Pass it via `env:` in the step that uses the Action (see +`terraform-plan.yml`). Without any key the Action degrades silently to +the templated narrative — the PR comment is still posted, just less +narrated. + +## Action inputs + +| Input | Required | Default | What it does | +|-------|----------|---------|--------------| +| `plan-file` | yes | — | Path to `terraform show -json` output | +| `region` | no | `us-east-2` | AWS region for pricing | +| `output-file` | no | `` (empty) | Also write the Markdown to this file (e.g. for artefact upload) | +| `marker` | no | `cloudoracle-pr-v1` | HTML comment marker for upsert | +| `no-llm` | no | `false` | Force templated narrative | +| `github-token` | no | `${{ github.token }}` | Token for the comment POST/PATCH | + +## Behaviour notes + +- The Action only **posts** when `GITHUB_EVENT_NAME` is `pull_request` + or `pull_request_target`. Other events render the Markdown and exit; + use `output-file` to capture it elsewhere. +- The Action exits with differentiated codes: + - `0`: success + - `1`: input error (missing plan file, bad flags) + - `2`: pricing error (AWS API failure) + - `3`: output error (file write failed) + - `4`: GitHub error (post/update failed) diff --git a/.github/examples/terraform-plan-no-llm.yml b/.github/examples/terraform-plan-no-llm.yml new file mode 100644 index 0000000..9561c43 --- /dev/null +++ b/.github/examples/terraform-plan-no-llm.yml @@ -0,0 +1,42 @@ +name: Terraform Plan with Cost Comment (No LLM) + +# Minimal example for teams that: +# - cannot or do not want to send plan data to an LLM provider +# - haven't set up OIDC and prefer long-lived AWS access keys +# - want the smallest possible permissions surface +# +# The output is the deterministic templated narrative ("This plan adds +# N resources..."), which is functional but less informative than the +# LLM-narrated version in terraform-plan.yml. +on: + pull_request: + paths: ['**.tf'] + +permissions: + pull-requests: write + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - uses: hashicorp/setup-terraform@v3 + + - run: terraform init + + - run: terraform plan -out=tf.plan + + - run: terraform show -json tf.plan > tf-plan.json + + - uses: Cro22/CloudOracle@v2.0.0 + with: + plan-file: tf-plan.json + no-llm: 'true' diff --git a/.github/examples/terraform-plan.yml b/.github/examples/terraform-plan.yml new file mode 100644 index 0000000..974cbb4 --- /dev/null +++ b/.github/examples/terraform-plan.yml @@ -0,0 +1,64 @@ +name: Terraform Plan with Cost Comment + +# Triggers on PRs that touch Terraform files. The path filter is a soft +# optimisation: jobs are skipped when no .tf-shaped files changed, so a +# PR editing only README.md doesn't burn a runner minute on a no-op cost +# comment. Tighten the patterns to your monorepo's layout if needed. +on: + pull_request: + paths: + - '**.tf' + - '**.tfvars' + - '.terraform.lock.hcl' + +permissions: + pull-requests: write # CloudOracle needs this to post/update the comment. + id-token: write # Required by aws-actions/configure-aws-credentials when using OIDC. + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # OIDC role assumption — the recommended path for cloud auth in + # CI. No long-lived AWS credentials sitting in repo secrets. See + # README.md in this directory for a sample IAM trust policy. + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsCloudOracle + aws-region: us-east-2 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.0 + + - name: Terraform init + run: terraform init + + - name: Terraform plan + run: terraform plan -out=tf.plan + + - name: Convert plan to JSON + run: terraform show -json tf.plan > tf-plan.json + + # CloudOracle reads tf-plan.json, queries the AWS Pricing API for + # each changed resource, asks the LLM for a 1-3 sentence narrative, + # and posts/upserts a comment on the PR using the workflow token. + - name: CloudOracle cost analysis + uses: Cro22/CloudOracle@v2.0.0 + with: + plan-file: tf-plan.json + region: us-east-2 + env: + # The LLM auto-detects which provider to use based on which key + # is set; you only need one of these. Pick whichever provider + # your org has procurement for. Without any key, CloudOracle + # falls back silently to the templated narrative. + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + # OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/Dockerfile.action b/Dockerfile.action new file mode 100644 index 0000000..0a2202c --- /dev/null +++ b/Dockerfile.action @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1.6 +# +# CloudOracle Action image (Hito 16.4). +# +# Builds the `oracle` CLI as a static binary and packages it with the +# entrypoint shim that adapts GitHub Action inputs to `pr-check` flags. +# This Dockerfile is independent from the root Dockerfile (which still +# builds the v1 dashboard image); action.yml references this file +# explicitly via `image: 'Dockerfile.action'`. + +# --- Build stage ---------------------------------------------------------- +FROM golang:1.25-alpine AS build +WORKDIR /src + +# Module cache layer first so dep changes don't bust the source layer. +COPY go.mod go.sum ./ +RUN go mod download + +# Copy only what `go build ./cmd/oracle` needs. The web/ frontend and +# probe-pricing helper are excluded via .dockerignore at the project +# root, but a narrower COPY here keeps the build resilient even if +# .dockerignore drifts. +COPY cmd ./cmd +COPY internal ./internal + +# internal/api uses `//go:embed all:dist` to bundle the v1 dashboard +# frontend. The Action build doesn't include the React frontend (the +# `serve` subcommand still compiles in but isn't reachable through the +# Action entrypoint), and `go:embed` requires the matched directory to +# contain at least one file. Drop a placeholder so the embed directive +# resolves; serving the dashboard from this image would render the +# "dashboard bundle not found" fallback HTML — fine, since the Action +# never invokes `serve`. +RUN mkdir -p internal/api/dist && echo "stub" > internal/api/dist/.placeholder + +# CGO disabled → fully static binary, no glibc/musl runtime needed. +# -ldflags "-s -w" strips debug + symbol tables (~25MB → ~15MB). +# -trimpath removes local build paths from the binary, useful for +# reproducibility and to avoid leaking developer paths into stack traces. +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags="-s -w" \ + -o /out/oracle \ + ./cmd/oracle + +# --- Final stage ---------------------------------------------------------- +FROM alpine:3.19 + +# ca-certificates is required for HTTPS to AWS Pricing API and GitHub +# REST API. tzdata is omitted: the binary uses UTC for slog timestamps +# and we don't render local time anywhere. +RUN apk add --no-cache ca-certificates + +COPY --from=build /out/oracle /usr/local/bin/oracle +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index af0bd06..4d196da 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,173 @@ # CloudOracle -![Tests](https://img.shields.io/badge/tests-171%20unit%20%2B%2012%20integration-brightgreen) +![Tests](https://img.shields.io/badge/tests-469%20unit%20%2B%2021%20integration-brightgreen) ![Go Version](https://img.shields.io/badge/go-1.25-blue) ![License](https://img.shields.io/badge/license-Apache%20License%202.0-green) -A CLI tool built in Go that analyzes cloud infrastructure resources and detects cost optimization opportunities. It simulates a real-world FinOps workflow: ingesting cloud resource data, storing it in PostgreSQL, and running deterministic rules to surface waste such as idle EC2 instances, orphaned EBS volumes, oversized RDS databases, and over-provisioned Lambda functions. +A Go FinOps toolkit that ships in two modes from the same `oracle` binary: + +- **v1 — Audit existing cloud spend.** Ingest live EC2/RDS/EBS/Lambda inventory from AWS, GCP, or Azure into Postgres, run deterministic rules over it, and produce an executive PDF + dashboard with an LLM-narrated summary. The classic "what waste is already in our cloud bill?" workflow. +- **v2 — Predict cost impact of a Terraform PR before merge.** Read `terraform show -json plan.tfplan`, look every changing resource up against the AWS Pricing API, and post (or upsert) a Markdown comment on the PR with the net monthly delta, top movers, and a 1–3 sentence LLM narrative. Ships as a [GitHub Action](#v2--terraform-pr-cost-analysis-current-focus) and as the `oracle pr-check` subcommand. + +The v2 mode is the current focus — it's documented immediately below. The v1 audit mode is documented further down (["v1 — Cloud cost audit"](#v1--cloud-cost-audit)) and is fully functional. + +## v2 — Terraform PR cost analysis (current focus) + +CloudOracle parses a Terraform plan, prices every changing resource against the live AWS Pricing API, and renders a PR comment that looks like this: + +> ## 💰 Cloud Cost Impact +> **Net monthly change: +$389.35** 🔴 +> +> The Aurora cluster instance dominates this change at ~$204/month — over half the total. If this is intended for a non-production environment, an `aws_db_instance` running `db.t3.medium` would land around $60/mo for similar functional coverage. Note that data-processing charges for the NAT gateway are not modeled in this estimate. +> +> ### Top movers by cost impact +> | Resource | Action | Δ Monthly | Confidence | +> | -------- | ------ | --------- | ---------- | +> | `aws_rds_cluster_instance.aurora` | 🆕 create | +$204.40 | low | +> | `aws_db_instance.db` | 🆕 create | +$71.36 | low | +> | `aws_instance.web` | 🆕 create | +$64.74 | low | +> +> _
Full breakdown · Assumptions and caveats
_ +> +> Generated by [CloudOracle](...) · Confidence: **low** +> +> `` + +The HTML marker at the end is what makes re-renders safe: subsequent pushes update that comment in place instead of stacking new ones. + +### Quick start: GitHub Action + +Drop this into `.github/workflows/cost-comment.yml` in any repo with Terraform: + +```yaml +name: Terraform Plan Cost Comment +on: + pull_request: + paths: ['**.tf'] + +permissions: + pull-requests: write + id-token: write + contents: read + +jobs: + cost: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsCloudOracle + aws-region: us-east-2 + - uses: hashicorp/setup-terraform@v3 + - run: terraform init && terraform plan -out=tf.plan + - run: terraform show -json tf.plan > tf-plan.json + - uses: Cro22/CloudOracle@v2.0.0 + with: + plan-file: tf-plan.json + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} +``` + +Two reference workflows live under [`.github/examples/`](.github/examples) — one with OIDC + LLM, one with static AWS access keys + no-LLM fallback. The `.github/examples/README.md` covers IAM trust policies, the minimum permission set, and how to wire the LLM secret. + +### Action inputs + +| Input | Required | Default | Notes | +|-------|----------|---------|-------| +| `plan-file` | yes | — | Path to `terraform show -json` output. | +| `region` | no | `us-east-2` | AWS region the Pricing API queries against. | +| `output-file` | no | `` | Also write the rendered Markdown to a file (useful for artefact upload). | +| `marker` | no | `cloudoracle-pr-v1` | HTML-comment substring used for upsert. Bump if you change the comment template. | +| `no-llm` | no | `false` | Force the deterministic templated narrative even with LLM keys configured. | +| `github-token` | no | `${{ github.token }}` | Used to post the comment; needs `pull-requests: write`. | + +The Action only posts when `GITHUB_EVENT_NAME` is `pull_request` or `pull_request_target`; on other triggers it renders to stdout (or `output-file`) and exits, with a `::notice::` log line explaining why. + +### Quick start: CLI + +The same workflow runs locally without any GitHub plumbing — useful for testing, debugging, or iterating on the prompt: + +```bash +# Just render to stdout (no AWS creds needed for the templated narrative) +go run ./cmd/oracle pr-check \ + --plan-file=internal/iac/testdata/plan_simple_create.json \ + --no-llm + +# Render against a real plan + AWS Pricing API +terraform show -json my.tfplan > plan.json +go run ./cmd/oracle pr-check --plan-file=plan.json --region=us-east-2 + +# Render and post (or update) the comment on PR #11 +go run ./cmd/oracle pr-check \ + --plan-file=plan.json \ + --post --repo=Cro22/CloudOracle --pr=11 \ + --token=$GITHUB_TOKEN +``` + +Full flag listing: + +| Flag | Default | Notes | +|------|---------|-------| +| `--plan-file` | — | Required. Path to JSON plan. | +| `--region` | `us-east-2` | AWS region for pricing. | +| `--output` | _(stdout)_ | File to also write the Markdown to; `-` or empty means stdout. | +| `--no-llm` | `false` | Force templated narrative. | +| `--post` | `false` | Post / upsert the comment via the GitHub API. Requires `--repo` and `--pr`. | +| `--repo` | — | `owner/name` form. Required with `--post`. | +| `--pr` | `0` | PR number. Required with `--post`. | +| `--token` | _(env)_ | Falls back to `$GITHUB_TOKEN` when empty. | +| `--marker` | `cloudoracle-pr-v1` | HTML comment marker for upsert. | + +Exit codes are differentiated so the Action wrapper can produce sensible CI error messages: + +| Code | Meaning | +|------|---------| +| 0 | Success. | +| 1 | Input error (missing/invalid flag, plan file unreadable). | +| 2 | Pricing error (AWS Pricing API rejected the request). | +| 3 | Output error (couldn't write `--output` path). | +| 4 | GitHub error (post/update failed). | + +### LLM narrative behavior + +The PR narrative is generated by the same provider layer as v1 (Gemini / Claude / OpenAI), so the same env-var conventions apply: set `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `OPENAI_API_KEY`, optionally pin one with `LLM_PROVIDER`. With no key configured, the comment falls back silently to a deterministic templated narrative — the comment still posts, just less narrated. + +The v2 prompt (in `internal/diff/narrative.go`) is purpose-built for PR review tone: 1–3 sentences, identifies the dominant cost driver, optionally suggests an architectural alternative (never a billing-model swap), avoids cheerleading. Caveats are grouped by resource so the model can't accidentally attribute one resource's note to another (e.g. mistakenly claiming the database carries the NAT gateway's data-processing charges — a real bug observed during prompt development that the grouping prevents). + +### v2 architecture + +``` +internal/iac/ # Terraform plan parser + terraform.go # ParsePlan / ParsePlanFile + the canonical Plan model + aws/ # AWS-specific resource shape decoders (after_unknown handling, attr extraction) +internal/pricing/ # AWS Pricing API client + per-service estimators + aws.go # *pricing.Client wrapping the AWS SDK + cache.go # 7-day disk cache (best-effort) keyed by service+filters + ec2.go / ebs.go / rds.go / lambda.go / nat.go # one estimator per supported resource type + estimator.go # EstimateChange entry point — dispatches to the right estimator +internal/diff/ # CostDiff aggregation + Markdown rendering + engine.go # Analyze: per-resource estimates -> CostDiff (Created/Deleted/Updated/Replaced/Skipped) + markdown.go # template-based PR comment renderer (header / table / breakdown / caveats / footer) + narrative.go # LLM narrative + grouped caveats + silent fallback to templated text +internal/github/ # Thin GitHub REST client (issue comments only) + client.go / comments.go # listComments (paginated, capped) + postComment + updateComment + PostOrUpdateComment +cmd/oracle/ # pr-check subcommand wires it all together + main.go # runPRCheck: ParsePlan -> Analyze -> Render -> [Post] +Dockerfile.action # Multi-stage golang:1.25-alpine -> alpine:3.19, ENTRYPOINT entrypoint.sh +entrypoint.sh # POSIX shim: INPUT_* env vars -> oracle pr-check flags +action.yml # GitHub Action manifest (runs: docker, image: Dockerfile.action) +``` + +The v1 dashboard `Dockerfile` at the repo root is **untouched** — `Dockerfile.action` is a separate, leaner image just for the Action. They share a single `.dockerignore`. + +### Supported resources (v2) + +EC2 instances (Linux on-demand compute + root EBS), EBS volumes (gp2/gp3/io1/io2/st1/sc1), RDS instances (single-AZ + Aurora cluster instances), Lambda functions (cold-start estimate), NAT gateways (hourly only). Unsupported types appear in the rendered comment under "Skipped" with a one-line reason — they don't fail the run. Adding a new resource type is one new file under `internal/pricing/` plus a switch case in `estimator.go`. + +--- + +## v1 — Cloud cost audit ## Why this project? @@ -39,10 +202,12 @@ Unlike policy engines like **Cloud Custodian** that focus on automated enforceme - **Export findings to JSON or CSV** - Pipe analyzer output into downstream tooling (dashboards, spreadsheets, ticket systems) via `oracle export --format=json|csv`, writing to stdout or a file - **Single-binary web dashboard** - React + Recharts UI embedded into the Go binary via `go:embed`; `oracle serve` boots API and dashboard on one port with no external assets required -## Architecture +## Architecture (v1) + +> The v2 packages (`internal/iac`, `internal/pricing`, `internal/diff`, `internal/github`) are documented in the [v2 architecture](#v2-architecture) section above. The tree below is the v1 audit-mode layout. ``` -cmd/oracle/main.go # CLI entry point (seed, list, analyze, report, trend) +cmd/oracle/main.go # CLI entry point (seed, list, analyze, report, trend, pr-check) internal/ config/ config.go # Central Config + Load(): reads every env var up front @@ -628,9 +793,20 @@ Building this project surfaced a subtle but important bug that would have gone u ## Roadmap +### v2 — Terraform PR cost analysis +- [x] Terraform plan parser — `internal/iac` reads `terraform show -json` into a typed `Plan` model with action classification (create / update / replace / delete / no-op) and `after_unknown` handling +- [x] AWS Pricing API client + cache — `internal/pricing.Client` wraps AWS SDK v2 `pricing:GetProducts`; `internal/pricing.Cache` adds a 7-day disk cache keyed by service+filters +- [x] Per-resource estimators — EC2, EBS, RDS, Aurora cluster instance, Lambda, NAT gateway with breakdown line items and assumption notes +- [x] CostDiff aggregator — `internal/diff.Analyze` collapses per-resource estimates into a plan-wide picture with Created / Deleted / Updated / Replaced / Skipped slices, top movers, and aggregate confidence +- [x] Markdown renderer — `internal/diff.RenderMarkdown` produces the canonical PR comment (header / top movers table / full breakdown / caveats / marker footer), templated and golden-tested +- [x] LLM-narrated PR comment — `RenderMarkdownWithLLM` swaps the templated narrative for a 1–3 sentence LLM output with caveat grouping, sanity checks (length cap, preamble strip, paragraph-break warn), and silent fallback to the templated text on any failure +- [x] GitHub REST client — `internal/github.PostOrUpdateComment` lists, finds-by-marker, and PATCHes / POSTs; paginated with cap, body truncation guard at 60KB, multi-match resolution to most-recently-updated +- [x] `oracle pr-check` subcommand — orchestrates the whole pipeline, with differentiated exit codes (1 input / 2 pricing / 3 output / 4 github) and `--no-llm` / `--post` switches +- [x] GitHub Action packaging — `Dockerfile.action`, `action.yml`, POSIX `entrypoint.sh` that auto-extracts the PR number from `GITHUB_REF` on `pull_request[_target]` events; reference workflows under `.github/examples/` + +### v1 — Cloud cost audit - [x] LLM-powered analysis: executive summaries generated by Gemini / Claude / OpenAI - [x] PDF report generation with executive summary and severity-coded tables -- [x] Test suite: 103 unit tests across analyzer, generator, LLM providers, PDF, export, config, and cloud mapping - [x] Real AWS integration via SDK (EC2, RDS, EBS, Lambda with STS validation and graceful degradation) - [x] Multi-cloud support (GCP, Azure) with Compute, SQL, Disks, and Functions for each provider - [x] Cost trend tracking over time (automatic snapshots on seed + `trend` command) diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..eee5cee --- /dev/null +++ b/action.yml @@ -0,0 +1,36 @@ +name: 'CloudOracle PR Cost Comment' +description: 'Posts a cost-impact analysis comment on Terraform pull requests, with an LLM-generated narrative.' +author: 'Cro22' + +branding: + icon: 'dollar-sign' + color: 'green' + +inputs: + plan-file: + description: 'Path to the JSON output of `terraform show -json plan.tfplan`. Required.' + required: true + region: + description: 'AWS region for Pricing API queries.' + required: false + default: 'us-east-2' + output-file: + description: 'Optional path to also write the rendered Markdown to a file (in addition to posting). Useful for uploading the comment as an artefact.' + required: false + default: '' + marker: + description: 'HTML comment marker used to find the existing CloudOracle comment for upsert. Bump if you change the comment template format.' + required: false + default: 'cloudoracle-pr-v1' + no-llm: + description: 'If "true", disables the LLM narrative and falls back to the templated text. Use when LLM keys are unavailable or disallowed.' + required: false + default: 'false' + github-token: + description: 'GitHub token used to post the comment. Defaults to the workflow token. Requires `pull-requests: write` permission.' + required: false + default: ${{ github.token }} + +runs: + using: 'docker' + image: 'Dockerfile.action' diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..de60944 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# CloudOracle Action entrypoint (Hito 16.4). +# +# Translates GitHub Action `inputs:` (delivered as INPUT_* env vars) into +# `oracle pr-check` flags, then exec's the binary so its exit code is the +# Action's exit code (1=input, 2=pricing, 3=output, 4=github). +# +# POSIX-only — no bashisms — because the alpine base ships /bin/sh as +# busybox ash. Run shellcheck under -s sh to catch regressions. +set -eu + +# --- Required input ------------------------------------------------------- +if [ -z "${INPUT_PLAN_FILE:-}" ]; then + echo "::error::plan-file input is required" >&2 + exit 1 +fi + +# --- Build the argv incrementally ---------------------------------------- +# `set -- ...` rewrites positional parameters; each `set -- "$@" ...` line +# appends to the existing argv. This is the POSIX-portable way to build +# a list when arrays aren't available. +set -- --plan-file="${INPUT_PLAN_FILE}" +set -- "$@" --region="${INPUT_REGION:-us-east-2}" +set -- "$@" --marker="${INPUT_MARKER:-cloudoracle-pr-v1}" + +if [ "${INPUT_NO_LLM:-false}" = "true" ]; then + set -- "$@" --no-llm +fi + +if [ -n "${INPUT_OUTPUT_FILE:-}" ]; then + set -- "$@" --output="${INPUT_OUTPUT_FILE}" +fi + +# --- Auto-post on pull_request[_target] events --------------------------- +# GitHub sets GITHUB_EVENT_NAME and GITHUB_REF for us. For PR events, +# GITHUB_REF takes the form `refs/pull/{N}/merge` (default) or +# `refs/pull/{N}/head` (when checkout-merge-commit:false is configured). +# We strip the `refs/pull/` prefix, then strip the trailing `/merge` or +# `/head` to get just the PR number. If the ref doesn't match that +# shape, the substitution is a no-op (`PR_REF == GITHUB_REF`) and we +# skip posting with a warning rather than blindly POSTing with a bad +# value. +event="${GITHUB_EVENT_NAME:-}" +if [ "$event" = "pull_request" ] || [ "$event" = "pull_request_target" ]; then + PR_REF="${GITHUB_REF#refs/pull/}" + PR_NUMBER="${PR_REF%/*}" + + if [ -n "$PR_NUMBER" ] && [ "$PR_REF" != "${GITHUB_REF:-}" ]; then + set -- "$@" --post + set -- "$@" --repo="${GITHUB_REPOSITORY}" + set -- "$@" --pr="${PR_NUMBER}" + if [ -n "${INPUT_GITHUB_TOKEN:-}" ]; then + set -- "$@" --token="${INPUT_GITHUB_TOKEN}" + fi + else + echo "::warning::Could not extract PR number from GITHUB_REF=${GITHUB_REF:-}; rendering only, not posting." >&2 + fi +else + echo "::notice::Not a pull_request event (event=${event:-}); rendering only, not posting." >&2 +fi + +exec /usr/local/bin/oracle pr-check "$@" From 3ac3e34810c225eea982b09a944cb72b38b5084b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Sat, 9 May 2026 12:32:44 -0400 Subject: [PATCH 19/21] test: add in-repo Action self-test workflow with toy Terraform plan Wires up a `cost-self-test` workflow that exercises the CloudOracle Action via `uses: ./` against a single-resource Terraform plan in `e2e-test/`, so changes to action.yml / Dockerfile.action / entrypoint.sh / pr-check are validated end-to-end on every PR before a release tag. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/cost-self-test.yml | 67 ++++++++++++++++++++++++++++ e2e-test/main.tf | 28 ++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 .github/workflows/cost-self-test.yml create mode 100644 e2e-test/main.tf diff --git a/.github/workflows/cost-self-test.yml b/.github/workflows/cost-self-test.yml new file mode 100644 index 0000000..09a8f00 --- /dev/null +++ b/.github/workflows/cost-self-test.yml @@ -0,0 +1,67 @@ +name: Action self-test (Cost Comment) + +# In-repo smoke test for the CloudOracle Action. Runs the Action +# against the toy Terraform plan in e2e-test/, using `uses: ./` so +# Docker builds the image from the current checkout instead of +# pulling a published tag. This catches regressions in the Action +# manifest, Dockerfile.action, entrypoint.sh, or the pr-check +# command before they reach a tagged release. + +on: + pull_request: + branches: [main] + paths: + - 'action.yml' + - 'Dockerfile.action' + - 'entrypoint.sh' + - 'cmd/oracle/**' + - 'internal/iac/**' + - 'internal/pricing/**' + - 'internal/diff/**' + - 'internal/github/**' + - 'e2e-test/**' + - '.github/workflows/cost-self-test.yml' + workflow_dispatch: + +permissions: + pull-requests: write + contents: read + +jobs: + cost-impact: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-2 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.6.0 + + - name: Terraform init + working-directory: e2e-test + run: terraform init + + - name: Terraform plan + working-directory: e2e-test + run: terraform plan -out=tf.plan + + - name: Convert plan to JSON + working-directory: e2e-test + run: terraform show -json tf.plan > tf-plan.json + + # `uses: ./` builds Dockerfile.action from the checked-out tree, + # so any changes to the Action code on this branch are exercised + # end-to-end before publishing a tag. + - name: CloudOracle (in-repo build) + uses: ./ + with: + plan-file: e2e-test/tf-plan.json + region: us-east-2 + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/e2e-test/main.tf b/e2e-test/main.tf new file mode 100644 index 0000000..0918b45 --- /dev/null +++ b/e2e-test/main.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.5" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.0" + } + } +} + +provider "aws" { + region = "us-east-2" +} + +# Single t3.micro on Linux on-demand — a small but non-zero cost diff +# (~$7-8/month) so the rendered comment exercises the Top-movers table +# and the LLM narrative path. The AMI is a well-known Amazon Linux 2 +# image in us-east-2; terraform plan does not resolve it (no data +# sources), so the value flows straight into the plan JSON for the +# CloudOracle parser to read. +resource "aws_instance" "self_test" { + ami = "ami-0c55b159cbfafe1f0" + instance_type = "t3.micro" + + tags = { + Name = "cloudoracle-self-test" + } +} From 70d7ab827e42cd5e6c249bf1288e2b1e35ff857f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Sat, 9 May 2026 12:37:45 -0400 Subject: [PATCH 20/21] fix: update environment variable from ANTHROPIC_API_KEY to GEMINI_API_KEY in cost self-test workflow --- .github/workflows/cost-self-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cost-self-test.yml b/.github/workflows/cost-self-test.yml index 09a8f00..e67fad7 100644 --- a/.github/workflows/cost-self-test.yml +++ b/.github/workflows/cost-self-test.yml @@ -64,4 +64,4 @@ jobs: plan-file: e2e-test/tf-plan.json region: us-east-2 env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} From 145c8bad1fc4d3754ea4ea70467606774cfdbe20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jesus=20Nu=C3=B1ez?= Date: Sat, 9 May 2026 12:46:05 -0400 Subject: [PATCH 21/21] feat: update Terraform version to latest in cost self-test workflow --- .github/workflows/cost-self-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cost-self-test.yml b/.github/workflows/cost-self-test.yml index e67fad7..61ae99a 100644 --- a/.github/workflows/cost-self-test.yml +++ b/.github/workflows/cost-self-test.yml @@ -41,7 +41,7 @@ jobs: - uses: hashicorp/setup-terraform@v3 with: - terraform_version: 1.6.0 + terraform_version: latest - name: Terraform init working-directory: e2e-test