<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Pudding Entertainment on Medium]]></title>
        <description><![CDATA[Stories by Pudding Entertainment on Medium]]></description>
        <link>https://medium.com/@pudding-entertainment?source=rss-357fbf3e95e1------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/2*JFLmfzyf3c7mKQLhtFarww.jpeg</url>
            <title>Stories by Pudding Entertainment on Medium</title>
            <link>https://medium.com/@pudding-entertainment?source=rss-357fbf3e95e1------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sat, 14 Nov 2026 22:20:35 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@pudding-entertainment/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com/" rel="hub"/>
        <item>
            <title><![CDATA[Running Home Assistant Voice Satellite on Raspberry Pi 5 in 2026]]></title>
            <link>https://pudding-entertainment.medium.com/running-a-home-assistant-voice-satellite-on-a-raspberry-pi-5-in-2026-a3616c9ba9c2?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/a3616c9ba9c2</guid>
            <category><![CDATA[voice-assistant]]></category>
            <category><![CDATA[smart-home]]></category>
            <category><![CDATA[hailo]]></category>
            <category><![CDATA[home-assistant]]></category>
            <category><![CDATA[raspberry-pi]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 30 Aug 2026 21:36:12 GMT</pubDate>
            <atom:updated>2026-09-11T14:06:19.972Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*oyn6GNkEKWhK9mBrsCrYZQ.png" /><figcaption>Image generated by ChatGPT 5.6</figcaption></figure><h3>Intro</h3><p>I had always been curious about having a smart home, but I never really had a reason to build one. That changed when I unexpectedly got a Tapo smart socket and used it to control a standing lamp in the living room.<br>After that came a Dyson fan and a couple of Xiaomi devices. Before long, I had a small zoo of smart gadgets, each with its own app. Managing them all from separate apps felt unnecessarily complicated, so I started thinking that voice control might be a simpler way to bring everything together.</p><p>I had a Raspberry Pi 5 running Ubuntu but no Home Assistant yet, so I installed it and decided to see how far I could get with a local voice setup.</p><p>The target setup looked simple enough:</p><pre>USB conference speakerphone<br>    ↓<br>Raspberry Pi 5<br>    ↓<br>local wake word<br>    ↓<br>local speech-to-text<br>    ↓<br>Home Assistant Assist<br>    ↓<br>local text-to-speech</pre><p>I also had a Raspberry Pi AI HAT+ 2 installed, which made the idea even more tempting: maybe some of the speech workload could eventually move to the Hailo accelerator instead of burning Pi CPU. <em>But more about it later</em>.</p><p>For the voice hardware, I used an <a href="https://www.soundcore.com/products/a3308011?variant=47901374742718">Anker PowerConf S330</a>. It is a USB conference speakerphone with built-in microphones and a speaker, which sounds almost perfect for this kind of project. No battery, Bluetooth or proprietary smart-speaker firmware, just USB audio plugged into a Linux box.</p><p>On paper, this looked like a setup that should work. And in many ways, it did. Home Assistant managed the devices, TTS worked, the S330 showed up as normal Linux audio, and the whole thing could be wired into a local voice pipeline. But testing it from the couch, about 2.5 meters away, quickly showed the gap between a demo and something I would actually use every day. The wake word often worked, but the rest of the interaction was unreliable: commands were misheard, interpreted incorrectly, or answered much too late. A simple “turn the lights off” was enough to make the whole setup feel inconsistent rather than useful.</p><p>In this story, I’ll share what I tried, what worked, what failed, and why I eventually returned the speakerphone and parked the idea until the local voice ecosystem gets better. This is not a happy “here is how to build your own Alexa replacement” guide. I didn’t manage to build one.</p><h3>The “voice pipeline” project</h3><p>Once the devices were controllable from Home Assistant, the real project became the voice pipeline. I used <a href="https://github.com/OHF-Voice/linux-voice-assistant">OHF Linux Voice Assistant</a> for the satellite side. It connects to Home Assistant through the ESPHome-compatible API and can use Home Assistant’s Assist pipeline without needing Home Assistant OS or the add-on store.</p><p>The basic audio path looked like this:</p><pre>Anker PowerConf S330<br>  ↓<br>PipeWire<br>  ↓<br>Linux Voice Assistant<br>  ↓<br>Home Assistant Assist<br>  ↓<br>Speech-to-text (STT)<br>  ↓<br>Intent and conversation handling<br>  ↓<br>Piper text-to-speech (TTS)<br>  ↓<br>Linux Voice Assistant<br>  ↓<br>PipeWire<br>  ↓<br>Anker PowerConf S330</pre><p>Because I was running Home Assistant Container on Ubuntu, I had to treat the Pi more like a regular Linux machine than a dedicated appliance. Audio was handled by PipeWire in my normal Linux user’s session, while Home Assistant and the Wyoming speech services ran independently in Docker containers. The S330 itself was not difficult to expose to Linux. It appeared as a normal USB audio device with both microphone input and speaker output.</p><p>The first pleasant surprise was the wake word. I used “Okay Nabu”, and it worked better than I expected. Not perfectly, but reliably enough. Once the assistant woke up, though, the whole experience depended on speech-to-text. That was where the project stopped being a setup exercise and became a debugging exercise.</p><p>My first general-purpose STT backend was Faster Whisper on the Pi 5. I started with base-int8, which was accurate enough to make the project feel possible. At the same time, however, it was frequently slow, so short commands could take up to a minute to receive a response. When that happened, the assistant just sat there processing, with no way to terminate it prematurely.</p><p>Yet I wasn’t ready to give up, so I tried the more obvious Home Assistant-native option: Speech-to-Phrase. Conceptually, it is exactly what a smart-home assistant should use. Most of the time, I was not dictating free-form text. I was saying short commands against a known set of entities:</p><pre>turn the lights on<br>turn the lights off<br>turn on the white fan<br>turn off the Dyson</pre><p>A constrained recognizer should have a much easier job than a general-purpose speech model. It knows the grammar, it knows the device names, and it does not need to understand arbitrary English.</p><p>In practice, Speech-to-Phrase was promising, but not enough by itself. It was fast, and when it worked, it felt much closer to the kind of response time I wanted. But around 50% of the time, it simply returned no transcript, which meant the command was not recognized. When speech-to-text returned no transcript, the Assist pipeline terminated with stt-no-text-recognized before reaching the TTS stage. Linux Voice Assistant exposed this as a pipeline_error, for which its peripheral API recommends a visual error indication, but there was no spoken “sorry, I didn’t understand that” response. In practice, a failed command could therefore end in silence unless the satellite hardware provided some other feedback. <em>Of course, it might simply have been bad timing for this project, as </em><a href="https://github.com/OHF-voice/speech-to-phrase/issues/138"><em>I’m not alone experiencing this</em></a>.</p><p>At this point, I could confidently say that the pipeline was technically correct and that all services were running properly: the wake word worked, audio flowed through the system, Home Assistant could receive the transcript, match an intent, run the action, and speak back through Piper.</p><p>In day-to-day usage, however, the solution’s reliability and speed were below the minimum usability threshold. From the couch, or simply while passing by, it managed fewer than 50% of my commands. A deeper debugging session was therefore needed to answer a simple question: why was this thing failing on commands that were only three or four words long?</p><h3>Debugging the voice assistant</h3><p>At first, I assumed the microphone was simply not good enough. The S330 was sitting on the TV stand roughly 2.5 meters away from the couch. Conference speakerphones are designed for meetings, not for replacing a Google Home in a living room, so poor microphone quality seemed like the obvious explanation. So, I ran a few tests standing right next to the speaker, yet it still struggled with commands that should have been trivial.<br>And the failures also weren’t even that random. For some reason, commands containing <strong>“off”</strong> consistently seemed harder than commands containing <strong>“on”</strong>. Turn the lights on would mostly work, but Turn the lights off, would frequently fail.</p><p>I tried changing the wording:</p><pre>Turn off the lights<br>Lights off<br>Turn the white fan off<br>Turn off the white fan</pre><p>No obvious pattern emerged, sometimes one variation worked while another didn’t, only for the result to flip a few minutes later.</p><p>At this point I stopped guessing and started collecting evidence. Home Assistant can save the audio captured by the Assist pipeline, so instead of repeatedly speaking new commands and trying to judge them by ear, I enabled pipeline debugging and began analyzing the recorded WAV files. That immediately weakened several of my earlier theories. The recordings were clean — very little background noise, healthy signal levels and, most importantly, the supposedly troublesome words were actually there: the final <strong>“off”</strong> was audible and the microphone wasn’t obviously cutting it out. To a human listener, the commands were completely understandable. <br>It made it increasingly difficult to blame the S330 mic quality.</p><p>Speech-to-Phrase, which had frequently returned no transcript during normal use, correctly recognized six of the seven test recordings and usually finished in less than two seconds. Whisper also recognized the recordings, giving me a useful comparison with a more general-purpose transcription engine.</p><p>At first that seemed almost contradictory. If the recorded audio was good enough for both Whisper and Speech-to-Phrase, why did the same system feel so unreliable when used normally?</p><p>I’m not an expert in either system — far from it, really — but as a technical person, I have a few guesses about what might be happening here.</p><ul><li>One possible explanation is that live recognition introduces timing and streaming variables that are not present when replaying a complete recording. The system has to decide when speech starts and stops, and a saved recording may give the recognizer a cleaner or more consistent input.</li><li>Another possibility is that Speech-to-Phrase is less forgiving than Whisper. Whisper is a general transcription service that tries to reproduce whatever words it hears, while Speech-to-Phrase is designed to recognize commands that Home Assistant already knows. If the result is close enough to one of those commands, Speech-to-Phrase succeeds; if not, it returns nothing at all. Whisper may still produce a rough or imperfect transcript where Speech-to-Phrase rejects the same audio.</li><li>That could explain why the system sometimes felt inconsistent. Small differences in timing, pronunciation or the way the audio was captured may have been enough to turn an otherwise understandable command into an empty response from Speech-to-Phrase, even though Whisper could still produce some text — although it would take considerably longer.</li></ul><p>I couldn’t prove which of these factors mattered most, but the replay tests suggested that the live capture process was at least part of the problem. However, the tests with both Speech-to-Phrase and Whisper showed that the microphone was producing understandable recordings, so I could at least rule out hardware issues. Something about the complete live-recording process was making the results unreliable.</p><p>Before writing it all off I decided to explore all currently available models in more depth. That turned into an interesting comparison between Whisper, Speech-to-Phrase, Qwen3-ASR, NVIDIA Parakeet and even Hailo-accelerated Whisper.</p><h3>Trying different STT models</h3><p>The next step was to try different speech-to-text backends and see whether any of them made the system usable in real life.</p><h4>Speech-to-Phrase</h4><p>Speech-to-Phrase was the least interesting backend to benchmark because its strengths and limitations were already visible from the design. It was not trying to be a general dictation engine but rather a “mapper” of a small set of spoken phrases onto Home Assistant intents.</p><p>When I replayed the same seven recorded commands through it, it recognized six correctly and usually finished in roughly 1.5–2 seconds. That result was promising enough that I built a small STT fallback service around it.</p><p>The idea was simple:</p><pre>Speech-to-Phrase<br>      ↓<br>transcript returned? → use it<br>      ↓ no<br>Faster Whisper</pre><p>Speech-to-Phrase would handle the common Home Assistant commands quickly, while Whisper would act as a safety net whenever it failed to recognize anything.</p><p>The experiment worked technically. When Speech-to-Phrase succeeded, the fallback layer added almost no measurable overhead. When it returned an empty transcript, the same recording was automatically sent to Whisper instead.</p><p>But the design exposed another limitation: fallback could only help when Speech-to-Phrase returned nothing. If it confidently returned the wrong words, there was no reliable way to know that the result should be retried with another model. And more importantly, the good replay benchmark never materialized into equally reliable live use.</p><h4>Faster Whisper</h4><p>Faster Whisper was the first general-purpose STT backend I used. It was easy enough to run through Wyoming, and the quality was good enough to make the project feel realistic. However, latency was the problem.</p><p>With base-int8, the recognition was often acceptable, but some commands took more than 10 seconds to process. Occasionally, one bad input would send the decoder into a very long run, and the assistant would just sit there doing nothing useful. And for a voice assistant that makes a big difference. <br>Therefore I decided to try smaller Whisper models such as tiny-int8 and tiny.en, hoping that the latency would improve enough to make the trade-off worthwhile. And the smaller models were indeed faster, but the transcripts became too unreliable for device control. The recognized text was sometimes quite funny:</p><pre>Dice them on<br>The reason of<br>Wear the white fan on<br>Turn the wet fan on<br>Turn the lights home</pre><p>But that is the sort of failure that can make voice control dangerous rather than useful. If the model confuses a device name, drops the action, or changes “off” into something else, Home Assistant will end up doing the wrong thing.</p><p>So Whisper worked, but it did not feel appliance-grade on a Raspberry Pi 5.</p><h4>Qwen3-ASR</h4><p>Then I tried Qwen3-ASR through the newer Wyoming Faster Whisper support with rhasspy/qwen3-asr-0.6b-onnx-int4 model.</p><p>On the Pi 5, it was heavier than Speech-to-Phrase. During inference, it used roughly four CPU cores and around 2.5 GB of RAM. That is not outrageous on a 16 GB Pi 5, but it is still a noticeable amount of compute for a voice assistant.</p><p>And the fixed-WAV results were the best I had seen from a general STT model. For the same saved recordings, Qwen produced exactly the transcripts I would expect, so it was genuinely promising. <br>It also had another interesting feature: Home Assistant vocabulary biasing. In theory, that should help with exactly the kind of words that matter in a smart-home setup: entity names, room names, and aliases like white fan, Dyson, living room, and so on.</p><p>However, in live testing, it remained as unreliable as the previous two models. It could understand carefully separated speech, but natural commands were still hit or miss. For example, saying something like Turn lights on could fail, while a much more artificial version TURN … LIGHTS … ON was more likely to work.</p><p>At that point, the setup had crossed an important line for me. If I have to speak to my own voice assistant as though I am labelling a training dataset, the project has already failed as a living-room device.</p><p>Qwen3-ASR was still the best general-purpose CPU result I found, but it did not close the gap between “good benchmark” and “usable appliance”.</p><h4>Parakeet through ONNX-ASR</h4><p>The strangest result came from Parakeet.</p><p>I tested nemo-parakeet-tdt-0.6b-v2 through onnx-asr, and on the fixed WAV files it looked excellent. After warm-up, processing time was around 1.2 seconds, and most of the saved recordings were recognized correctly. For a moment, this looked like the best option: faster than Qwen, more accurate than tiny Whisper, and good enough on the exact command recordings I cared about. Until I tried it live. The transcripts immediately fell apart:</p><pre>The lights of<br>Dice and wall.<br>Uh fan of<br>Oh<br>I smoke.</pre><p>That was while I was saying variations of commands like:</p><pre>Turn the lights off<br>Turn the fan off.</pre><p>In real use, Parakeet was probably the worst backend I tried.</p><p>That result was useful, though, because it made the benchmark problem impossible to ignore. The same model could look excellent on a tiny set of saved WAV files and completely unusable during normal interaction.</p><p>After trying all of these, the results were pretty clear:</p><pre>Backend              | Good part                                        | Bad part<br>---------------------|--------------------------------------------------|-----------------------------------------<br>Speech-to-Phrase     | Fast and well matched to Home Assistant commands | Constrained, and still unreliable live<br>Faster Whisper tiny  | Lightweight                                      | Too inaccurate<br>Faster Whisper base  | Better transcription                             | Too slow and occasionally gets stuck<br>Qwen3-ASR 0.6B INT4  | Best general CPU result on saved recordings      | Still unreliable with natural live speech<br>Parakeet / ONNX-ASR  | Very fast and excellent on saved WAVs            | Terrible live behaviour in my setup</pre><p>The frustrating part was that nothing failed cleanly: the microphone, pipeline, wake word, Piper, and Home Assistant all worked well enough individually, and some models performed well in controlled tests, but the complete interaction still had too many edge cases. With voice control, “mostly works” is not good enough — a light switch that responds 70% of the time is just a worse light switch. Local STT on the Pi was technically impressive and occasionally useful, but too brittle for everyday use.</p><h3>Two side quests</h3><p>Completely optional, but still very interesting side projects I decided to explore while building everything.</p><h4>AI HAT+ 2</h4><p>While testing different CPU-based STT models, I also had one obvious question in the back of my mind: what about the AI HAT+ 2?</p><p>My Raspberry Pi 5 had a Hailo-10H accelerator attached, and speech-to-text looked like exactly the kind of workload that should benefit from dedicated inference hardware. If the Hailo accelerator could handle STT quickly and leave the CPU mostly free, that would be a much nicer architecture.</p><p>Running the same WAV file tests as before made the speed result very clear: Hailo Whisper inference took around <strong>0.8 seconds</strong>. But the actual recognition was disastrous. For the phrase:</p><pre>Turn off the white fan</pre><p>it produced something very funny:</p><pre>Turn of the white pen<br>Turn of the White Benton</pre><p>This made the AI HAT+ 2 result more interesting than simply saying “Hailo Whisper was not accurate enough”. The accelerator itself was clearly capable of running speech models quickly. The missing piece was the software interface around the model: contextual vocabulary, prompting, or some other reliable way to bias recognition toward my actual Home Assistant entities and commands.</p><p>For smart-home control, that context matters a lot. The STT engine does not need to understand the entire English language equally well. It needs to distinguish:</p><pre>lights<br>white fan<br>Dyson<br>turn on<br>turn off</pre><p>and it needs to do that reliably.</p><p>Without a good way to feed that context into the Hailo STT stack, the fast inference result was not enough. I could probably have spent more time building a custom Wyoming service around it, but if the available model still confused white fan with white pen, that would not solve the problem I actually had.</p><h4>S330 LEDs</h4><p>There was, however, one hardware side quest that worked much better than expected: the S330 LEDs. The Anker speakerphone exposes a HID interface, and after poking at it for a while, I found that some LED output reports were writable:</p><pre>Report 0x03 → call button blinks green<br>Report 0x04 → mute button turns steady red</pre><p>That was perfect for Linux Voice Assistant’s peripheral API.</p><p>I wrote a small service that listened to the assistant state and drove the LEDs accordingly:</p><pre>assistant active / processing → green call LED blinks<br>pipeline finished             → LED off<br>pipeline error                → red LED briefly lights</pre><p>This sounds like a tiny detail, but it made the system feel much more understandable. When the assistant was processing speech, the green LED kept blinking. When something failed, the red LED flashed. When the pipeline finished, the LEDs turned off.<br>That kind of feedback matters more than I expected. A spoken “sorry, I didn’t understand” response is nice, but it depends on the pipeline reaching the TTS stage. If STT fails early or times out, there may be no spoken response at all. A visual indicator can still work even when the voice pipeline itself is stuck or confused.</p><p>Ironically, this small HID detour felt more polished than several much larger parts of the project.</p><p>It did not fix recognition, of course. But it did make failures easier to understand, which is already a big improvement over a silent smart speaker that simply ignores you.</p><h3>Conclusion</h3><p>So in the end, the project failed.</p><p>Not completely, and not in an uninteresting way. The local stack worked well enough to prove that the idea is real: Home Assistant could control the devices, Linux Voice Assistant could act as a satellite, local STT and TTS could run on the Pi, and the whole thing could technically process voice commands without Google or Amazon involved.</p><p>But it failed at the thing that actually mattered: being usable every day.</p><p>A smart speaker is not judged by whether it works once in a demo. It is judged by whether it works when I walk past it, speak normally, and expect the light to turn off without thinking about microphone distance, model latency, grammar constraints, wake-word state, or whether the STT backend is about to hallucinate something ridiculous.</p><p>The individual parts were impressive, but the complete interaction was still too fragile. So I returned the Anker PowerConf S330 and parked the whole idea for now.</p><p>I still think local Home Assistant voice control is worth watching, the ecosystem is clearly moving in the right direction. The progress is real, and the building blocks are getting better. But at least for my setup, in 2026, “technically works” and “replaces a normal smart speaker” were still very different things.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=a3616c9ba9c2" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[What I Learned Rewriting a Production Go Service in Rust]]></title>
            <link>https://pudding-entertainment.medium.com/what-i-learned-rewriting-a-production-go-service-in-rust-ae0383fb52e0?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/ae0383fb52e0</guid>
            <category><![CDATA[coding]]></category>
            <category><![CDATA[golang]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 21 Jun 2026 20:29:21 GMT</pubDate>
            <atom:updated>2026-06-21T20:29:21.622Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*b3b6DV8pRv1pIm_ZFd1Lsw.png" /><figcaption>Image generated by ChatGPT 5.5</figcaption></figure><h3>Intro</h3><p>I recently finished rewriting a production service from Go to Rust.<br>It was not a small experiment or a side project. The service handles a high volume of requests, runs across several regions, and sits on a critical path for the business. A failed rewrite would have caused real problems.</p><p>Once the Rust version reached production, CPU usage dropped by roughly 2–3×. We were able to reduce the number of Kubernetes pods by more than half while serving the same traffic with the same latency.</p><p>The “<em>problem</em>” here is that it was a much larger improvement than I anticipated.</p><p>The obvious conclusion would be that Rust is simply two or three times faster than Go. But after benchmarking both implementations and examining the main request paths, the real explanation turned out to be more interesting. Some of the improvement came from Rust itself while a significant part came from problems in the original Go implementation that the rewrite forced me to notice and fix.</p><p>This article covers how I approached the rewrite, how I used coding agents without handing over control of the architecture, what happened when the service reached production, and where the performance improvement actually came from.</p><h3>The Approach</h3><p>The original Go service contained around 9,000 lines of production code. That is small enough to rewrite manually, but large enough for the work to take a meaningful amount of time. At the same time, I also wanted to test how useful modern coding agents could be for a real production rewrite.</p><p>I did not want to give an agent the repository and ask it to “rewrite this in Rust.” The service was too important for a one-shot conversion, and matching the old behavior was only part of the job. The new implementation also needed a clean architecture, predictable performance, useful tests, and code that the team could maintain after the rewrite was finished.</p><p>So I treated the agents as implementation tools rather than architects. I started by designing the Rust service end to end. I decided how configuration, HTTP handling, shared state, database access, metrics, and error handling should work before asking an agent to implement the larger parts. I then wrote several smaller components and tests myself. These served as reference implementations: the agents could follow existing patterns instead of inventing a new structure for every module.<br>After that, the rewrite was split into a sequence of small stages, each covering a specific part of the existing service. At the end of every stage, I reviewed the code and corrected the direction where necessary before moving on.<br>The existing integration-test suite made this approach much safer. It covered around 90% of the service’s behavior and all of its critical paths. A stage was not considered complete until the expected tests passed.<br>Once the Rust implementation passed the full suite, the existing deployment pipeline allowed me to deploy it to a single production node first. That gave us a controlled way to compare both versions under real traffic before expanding the rollout.</p><p>Under these conditions, coding agents reduced the amount of implementation work considerably. But they were effective because the difficult decisions had already been made: the architecture was defined, the patterns were visible, the work was divided into small steps, and every step had a clear testable outcome.</p><p>Both Claude Opus 4.6–4.8 and Codex 5.4–5.5 were used throughout the rewrite, and under those constraints they proved genuinely useful implementation partners.</p><h3>What the Service Does</h3><p>The service in question is a cookie-matching microservice used in an advertising platform. In AdTech, different platforms often assign their own identifiers to the same browser. Cookie matching allows them to associate those external identifiers with an internal user ID. That mapping can later be used to enrich bid requests and make advertising decisions.</p><p>The request path is fairly simple:</p><ol><li>The service receives an HTTP GET request containing a cookie</li><li>It validates and maps that value to an internal user ID</li><li>It stores the match in ScyllaDB</li><li>The ad server reads the mapping later when processing a bid request</li></ol><p>The service is stateless and deployed on Kubernetes across the US, Europe, and APAC. Although the business logic is not especially complicated, the endpoint handles a high volume of traffic, so small costs on the request path add up quickly. Each pod requested <strong>8 vCPUs and 2 GiB of memory</strong>, with average request latency staying below approximately <strong>20 ms</strong>.</p><p>Here is a high-level comparison of the two implementations:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/964/1*3IGIcoeZmeSUI-ciJhoGCQ.png" /><figcaption>Medium still has no native support for tables..</figcaption></figure><p>The Rust implementation ended up with less than half the production code and considerably less test code, while increasing the number of test cases.<br>That does not mean Rust automatically produces smaller programs. Some of the reduction came from removing duplication, simplifying abstractions, and redesigning parts of the service during the rewrite rather than translating the Go code line by line.<br>Still, it was an early sign that the new implementation was not merely a port. It was a chance to reconsider how the service worked internally.</p><h3>What Happened in Production</h3><p>Once the Rust service passed the full integration-test suite, I deployed it to a single production node. The initial rollout was intentionally small. I wanted to observe the new service under real traffic, compare its behavior with the Go version, and make sure there were no differences that our tests had missed. The service remained stable, so I gradually expanded the rollout.<br>The effect on resource usage was visible almost immediately. Under comparable traffic, the Rust implementation consumed roughly two to three times less CPU. Because the Kubernetes deployment scaled according to CPU usage, the lower consumption translated directly into fewer running pods.<br>The exact reduction varied with regional traffic, but the pattern was consistent. In absolute terms, each regional deployment typically went from three or four running pods to one or two while processing the same workload. All graphs are for the past 30 days from 21st June 2026:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Pc4CURW8pndfKPLRb67Jvw.png" /><figcaption>EU</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*erV_XRCCQuH5G9R9yOHJmQ.png" /><figcaption>US</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZH7CPwrjyMlB_h5eU8gW3w.png" /><figcaption>APAC</figcaption></figure><blockquote>That was far beyond what I had expected.</blockquote><p>I had assumed Rust would perform somewhat better. The service is request-heavy, does a fair amount of parsing, and sits on top of an asynchronous HTTP stack, so a reduction in CPU usage would not have been surprising. But a two-to-three-times improvement raised a more important question:</p><p><strong>Was Rust really that much faster, or had the rewrite also fixed problems in the original Go implementation?</strong></p><p>Production metrics could show the size of the improvement, but they could not explain where it came from. To answer that, I needed to benchmark both versions and break the request path into its main components.</p><h3>Finding the Source of the Improvement</h3><p>To investigate, I ran both implementations locally under the same conditions. Both services were compiled as optimized builds, pinned to the same four CPU cores, and connected to the same local ScyllaDB instance and Google Cloud Storage emulator.<br>Rather than comparing request latency alone, I measured the CPU time consumed by each process per request. This is close to the signal used by our production autoscaling setup and gives a better view of how much compute each implementation needs to process the same workload.</p><p>The load generator replayed requests using a dataset of 15,000 real requests. One of the goals was to exercise the service’s most expensive code paths, including browser and device identification (aka User-Agent), which was expected to be a significant source of CPU usage. I tested two traffic distributions:</p><ul><li><strong>Uniform:</strong> every user agent had the same probability of appearing. This creates an unrealistic worst case with a very low cache hit rate.</li><li><strong>Zipfian:</strong> a small number of common user agents appeared frequently, followed by a long tail of less common ones. This is much closer to real web traffic.</li></ul><p>I also benchmarked the main request paths individually and ran the Go service with garbage collection disabled to isolate how much of the difference came from the GC.<br>The realistic Zipfian replay produced a larger gap than we saw in production: approximately <strong>119 µs of CPU time per request in Go versus 33 µs in Rust</strong>, or around <strong>3.6×</strong>.</p><p>That did not mean every part of the Rust service was 3.6 times faster. The total was the result of several separate differences, and they did not all have the same explanation. Some were genuine advantages of Rust and its ecosystem. Others were inefficiencies in the original Go implementation that could have been fixed even before.</p><h3>Reason 1: User-Agent Parsing: The Biggest Contributor</h3><p>Every incoming request contains a User-Agent header. The service parses it to identify the browser and device, mainly so it can skip browsers where third-party cookie matching is unlikely to work, which turned out to be more expensive than it may sound.<br>The parser checks the raw header against the shared uap-core rule set, which contains hundreds of regular expressions. Running that full set on every request would be far too costly, so both implementations cache previously parsed user agents.</p><p>The Go code itself was only a thin wrapper around uap-go:</p><pre>func (p *parser) GetBrowser(ua string) string {<br>    return p.uap.ParseUserAgent(ua).Family<br>}</pre><p>The library handled caching internally. The Rust version did the same job through uaparser, with a custom cache added around it by me. So, both version cached the results, but the gap in performance came from two things:</p><ol><li>how effective the cache was</li><li>how expensive a cache miss was</li></ol><h4>The Go Cache Was Too Small and Shared</h4><p>uap-go uses a fixed 1,024-entry ARC cache shared across all goroutines and protected by a mutex:</p><pre>const cacheSize = 1024<br>c.userAgent, _ = lru.NewARC(cacheSize)</pre><p>My own Rust implementation used an 8,192-entry LRU cache per worker thread:</p><pre>thread_local! {<br>    static FAMILY_CACHE: RefCell&lt;LruCache&lt;String, String&gt;&gt; =<br>        RefCell::new(<br>            LruCache::new(NonZeroUsize::new(8192).unwrap())<br>        );<br>}</pre><p>That difference matters under realistic traffic. User-agent strings repeat, but there is also a long tail of less common values. A 1,024-entry shared cache fills quickly, and every access also has to pass through the same lock. The larger per-thread Rust cache held more useful entries and avoided cross-thread contention.<br>This was not an inherent Go limitation. The service could have placed a larger sharded cache in front of uap-go, or switched to another parser. But neither option was a one-line fix. The cache size inside uap-go is hardcoded and not configurable. Replacing the library would also require careful validation because other parsers may classify browsers differently, while the service relies on exact browser-family names.</p><p>So this part was technically fixable in Go, but it required a real refactor.</p><h4>Cache Misses Were Also Cheaper in Rust</h4><p>The second difference appeared whenever the requested user agent was not already cached. Both libraries use the same underlying uap-core rules, but the regex engines are different. In the benchmark, parsing an uncached user agent took approximately:</p><ul><li><strong>Go:</strong> 305 µs</li><li><strong>Rust:</strong> 131 µs</li></ul><p>That made the Rust parser about 2.3 times faster on a cache miss. Unlike the cache design, this was a more direct ecosystem advantage. Rust’s regex engine handled this workload considerably faster than Go’s standard regexp package. Because a single miss costs hundreds of microseconds, the overall service performance closely followed the cache hit rate.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/957/1*KMQtyoIeDU0Yj_lUifmkqg.png" /></figure><p>The realistic middle row is the most relevant. Under that workload, user-agent parsing accounted for roughly three-quarters of the total CPU gap between the two services. Rust had a genuine advantage when the parser had to execute the regex rules. But the original Go service also left a large amount of performance on the table through a small, shared, lock-protected cache driven by the choice of UA parsing library.</p><h3>Reason 2: Metric Label Sanitization: A Pure Go Mistake</h3><p>The service emits several Prometheus metrics for each request. Before a value could be used as a metric label, it passed through a small sanitization function. In the Go implementation, that function compiled its regular expression every time it was called:</p><pre>func sanitizePrometheusLabelValue(value string) string {<br>    re := regexp.MustCompile(`[^\w]+`)<br>    return strings.Trim(re.ReplaceAllString(value, &quot;_&quot;), &quot;_&quot;)<br>}</pre><p>That meant every request could trigger the same regular expression compilation several times. The cost of one call was not enormous — about <strong>1.3 µs</strong> — but it also created around <strong>18 allocations</strong>. On a high-volume request path, repeated small costs like this add up quickly.</p><p>The Rust version compiled the pattern once and reused it:</p><pre>static LABEL_SANITIZER: LazyLock&lt;Regex&gt; =<br>    LazyLock::new(|| Regex::new(r&quot;[^\w]+&quot;).unwrap());<br><br>...<br><br>pub fn sanitize_prometheus_label_value(value: &amp;str) -&gt; String {<br>    LABEL_SANITIZER<br>        .replace_all(value, &quot;_&quot;)<br>        .trim_matches(&#39;_&#39;)<br>        .to_string()<br>}</pre><p>That reduced the cost to roughly <strong>146 ns per call</strong>, almost nine times faster. But this was not a Rust advantage. The Go implementation could achieve nearly the same result by moving the compiled expression to package scope:</p><pre>var labelSanitizer = regexp.MustCompile(`[^\w]+`)<br><br>...<br><br>func sanitizePrometheusLabelValue(value string) string {<br>    return strings.Trim(<br>        labelSanitizer.ReplaceAllString(value, &quot;_&quot;),<br>        &quot;_&quot;,<br>    )<br>}</pre><p>This was simply a hot-path mistake in the original service — one that the rewrite happened to expose.</p><h3>Reason 3: GeoIP Decoding: Reading Far More Than We Needed</h3><p>The service performs a GeoIP lookup for every request to determine the client’s country. The Go implementation used the high-level Country() method from geoip2-golang:</p><pre>record, _ := d.reader.Country(parsedIP)<br>isoCode := record.Country.IsoCode</pre><p>The service only needed the two-letter ISO country code. However, the library decoded the full country record, including continent data, localized names, registered-country information, and other fields that the request path never used.</p><p>The Rust implementation took a narrower approach. It declared a small structure containing only the required fields:</p><pre>#[derive(Deserialize)]<br>struct CountryRecord&lt;&#39;a&gt; {<br>    #[serde(borrow)]<br>    country: Option&lt;CountryIso&lt;&#39;a&gt;&gt;,<br>}<br><br>#[derive(Deserialize)]<br>struct CountryIso&lt;&#39;a&gt; {<br>    iso_code: Option&lt;&amp;&#39;a str&gt;,<br>}</pre><p>The lookup then decoded only that projection:</p><pre>let iso = reader<br>    .lookup(ip)?<br>    .decode::&lt;CountryRecord&gt;()?<br>    .country<br>    .and_then(|country| country.iso_code);</pre><p>This reduced the lookup from approximately <strong>3.3 µs and 64 allocations in Go</strong> to around <strong>428 ns in Rust</strong>. At first glance, that looks like another large Rust win. But Go could do the same thing.</p><p>geoip2-golang is built on top of maxminddb-golang, and the lower-level decoder supports partial records. By using it directly, the Go implementation could declare only the field it needed, which would avoid decoding the unused sections of the database record:</p><pre>type countryRecord struct {<br>    Country struct {<br>        ISOCode string `maxminddb:&quot;iso_code&quot;`<br>    } `maxminddb:&quot;country&quot;`<br>}<br><br>var record countryRecord<br>_ = reader.Lookup(parsedIP, &amp;record)<br>isoCode := record.Country.ISOCode</pre><p>So, once again, the improvement did not come from something Go was incapable of doing. The original implementation had chosen a convenient high-level API, and the rewrite replaced it with a more focused hot-path implementation.</p><h3>Reason 4: The Runtime and Framework</h3><p>The rest of improvements was the general cost of processing a request: HTTP handling, allocations, middleware, shared state, and runtime overhead.<br>The Rust service used actix-web on Tokio, while the Go service used net/http. In this workload, the Rust stack required fewer allocations and had a lower baseline cost per request. This is also where the lack of a <strong>garbage collector</strong> helped — but much less than I initially expected.</p><p>Garbage collection is often the first explanation people reach for when comparing Go with Rust. To measure its actual impact, I ran the Go service with GOGC=off, disabling automatic collection during the benchmark.</p><p>CPU usage improved by only around <strong>8% on the base request path</strong>, equivalent to roughly <strong>4% of the total gap under realistic traffic</strong>. Go was still close to twice as expensive per request even with the collector disabled. And Rust does not have to pay that cost. But it was not the main reason for the overall result. The larger difference came from the combined cost of framework behavior and per-request allocations. Go could reduce some of that through further profiling and optimization, but Rust started from a lower floor.</p><p>This was the clearest part of the benchmark where the language and runtime choice itself made a lasting difference.</p><h3>The Results in One View</h3><p>Here are the main hot paths side by side:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/960/1*94isLTbZTPUOq0oCRN8lhA.png" /></figure><p>These numbers are useful, but they do not contribute equally to the final result. A user-agent cache miss costs hundreds of microseconds but happens only on some requests. Metric sanitization is much cheaper, but it runs several times per request. Garbage collection is spread across many requests rather than appearing as one explicit function call.</p><p>After weighting each cost by how often it occurred in the realistic Zipfian workload, the approximate breakdown of the <strong>86 µs saved per request</strong> looked like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/941/1*22xsDr_jjQ4o-RG7kqRd7A.png" /></figure><p>The user-agent number and the garbage-collection number came directly from end-to-end measurements and the GOGC=off test. The split between sanitization, GeoIP, and the remaining framework overhead is an estimate based on their individual costs and how often they run.<br>The proportions also depend on traffic. With a higher user-agent cache hit rate, parsing matters less and the base request overhead matters more. With a lower hit rate, parsing dominates even further.</p><h3>The Fair Takeaway</h3><p>The production result was real: the Rust service used two to three times less CPU and allowed each regional deployment to run on one or two pods instead of three or four. But the honest conclusion is not that Rust is automatically two or three times faster than Go. A large part of the improvement came from issues that could also have been fixed in the original service. Had we profiled and optimized the Go implementation first, the gap would have been considerably smaller.</p><p>Rust still had real advantages. Its regex engine handled cache misses much faster, its HTTP stack had a lower per-request cost in this workload, and its ownership model allowed the service to avoid much of the allocation and garbage-collection overhead present in Go. The rewrite also acted as a forcing function. Instead of carrying every old abstraction and implementation decision forward, I had to examine each part of the request path again. Some of the largest gains came not from translating the code, but from asking why the old code worked the way it did. Yet the language still determines the performance envelope available to the system.</p><p>And just as importantly, Rust turned out to be far less intimidating in practice than its reputation suggests, and the resulting codebase was generally smaller and easier to reason about than the one it replaced.</p><p>The lesson I take away is not merely that rewrites can be valuable. It is that a well-designed systems language like Rust gives you a stronger foundation to build high-throughput services on, and a rewrite is often the moment when those advantages become impossible to ignore.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=ae0383fb52e0" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Using Raspberry Pi 5 as your local AI coding agent]]></title>
            <link>https://pudding-entertainment.medium.com/using-raspberry-pi-5-as-your-local-ai-coding-agent-b44cab060cae?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/b44cab060cae</guid>
            <category><![CDATA[ollama]]></category>
            <category><![CDATA[raspberry-pi]]></category>
            <category><![CDATA[ai]]></category>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[generative-ai-tools]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 29 Mar 2026 18:54:04 GMT</pubDate>
            <atom:updated>2026-03-29T18:54:04.187Z</atom:updated>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*ZM3bG8CQzgM5j70HFeQ0uQ.png" /><figcaption>Image generated by ChatGPT 5.4</figcaption></figure><p>I bought a Raspberry Pi 5 with a fairly ambitious idea in mind: use it as a small local AI box for coding tasks.</p><p>Part of the motivation was practical. I wanted something that could take a bit of pressure off Claude API limits. Part of it was also simpler than that: I already had this surprisingly expensive little board, sold with plenty of “AI experiment” energy around it, and I wanted to find a real use for it.<br>Running a local coding model on a Pi sounded perfect on paper. Low power, always on, sitting on the desk, ready to help with tests, scripts, and small coding chores. In my head, it would become a kind of tiny local coding agent.</p><p>In practice, that is not what happened.</p><p>This article is a write-up of setting up Ollama and Aider on a Raspberry Pi 5, trying a few realistic coding tasks, and realizing that even the beefiest 16 GB Pi 5 is not really a substitute for a proper local coding machine.</p><h3>The idea</h3><p>The goal was modest enough. I was not expecting the Pi to rival a desktop GPU box or a cloud model. I just wanted it to do a few useful coding tasks locally:</p><ul><li>generate some unit test</li><li>help with small refactors</li><li>write tiny Bash/Python utilities</li><li>maybe handle quick back-and-forth tasks through OpenClaw from Telegram</li></ul><p>That felt reasonable. Modern small coding models are getting better, Ollama makes local serving easy, and running it through a CLI is very nice and easy with the help of Aider. So I decided to give it a try and set it up.</p><h3>Part 1. Ollama on the Pi</h3><p>The good news is that the setup part is actually quite easy.</p><p>I started with a pretty standard Ollama install:</p><pre>curl -fsSL https://ollama.com/install.sh | sh</pre><p>Then I checked that it was alive:</p><pre>ollama --version</pre><p>After that, I pulled a few coding models — I was experimenting with Qwen coder variants:</p><pre>ollama pull qwen2.5-coder:1.5b<br>ollama pull qwen2.5-coder:3b<br>ollama pull qwen2.5-coder:7b</pre><p>Lastly, I exposed ollama outside of Pi by editing it’s systemd unit — nano /etc/systemd/system/ollama.service — and adding the following two lines:</p><pre>Environment=&quot;OLLAMA_HOST=0.0.0.0:11434&quot;<br>Environment=&quot;OLLAMA_CONTEXT_LENGTH=8192&quot;</pre><p>After restarting the serivce you should be able to access it from within your local network</p><pre>curl http://&lt;pi-ip&gt;:11434/api/tags<br><br>{&quot;models&quot;:[{&quot;name&quot;:&quot;qwen2.5-coder:7b&quot;,&quot;model&quot;:&quot;qwen2.5-coder:7b&quot;,&quot;modified_at&quot;:&quot;2026-03-29T15:50:06.938558636+02:00&quot;,&quot;size&quot;:4683087561,&quot;digest&quot;:&quot;dae161e27b0e90dd1856c8bb3209201fd6736d8eb66298e75ed87571486f4364&quot;,&quot;details&quot;:{&quot;parent_model&quot;:&quot;&quot;,&quot;format&quot;:&quot;gguf&quot;,&quot;family&quot;:&quot;qwen2&quot;,&quot;families&quot;:[&quot;qwen2&quot;],&quot;parameter_size&quot;:&quot;7.6B&quot;,&quot;quantization_level&quot;:&quot;Q4_K_M&quot;}},{&quot;name&quot;:&quot;qwen2.5-coder:1.5b&quot;,&quot;model&quot;:&quot;qwen2.5-coder:1.5b&quot;,&quot;modified_at&quot;:&quot;2026-03-29T15:30:35.122956073+02:00&quot;,&quot;size&quot;:986062089,&quot;digest&quot;:&quot;d7372fd828518a4d38b1eb196c673c31a85f2ed302b3d1e406c4c2d1b64a0668&quot;,&quot;details&quot;:{&quot;parent_model&quot;:&quot;&quot;,&quot;format&quot;:&quot;gguf&quot;,&quot;family&quot;:&quot;qwen2&quot;,&quot;families&quot;:[&quot;qwen2&quot;],&quot;parameter_size&quot;:&quot;1.5B&quot;,&quot;quantization_level&quot;:&quot;Q4_K_M&quot;}},{&quot;name&quot;:&quot;qwen2.5-coder:3b&quot;,&quot;model&quot;:&quot;qwen2.5-coder:3b&quot;,&quot;modified_at&quot;:&quot;2026-03-29T14:51:00.437576892+02:00&quot;,&quot;size&quot;:1929912626,&quot;digest&quot;:&quot;f72c60cabf6237b07f6e632b2c48d533cef25eda2efbd34bed21c5e9c01e6225&quot;,&quot;details&quot;:{&quot;parent_model&quot;:&quot;&quot;,&quot;format&quot;:&quot;gguf&quot;,&quot;family&quot;:&quot;qwen2&quot;,&quot;families&quot;:[&quot;qwen2&quot;],&quot;parameter_size&quot;:&quot;3.1B&quot;,&quot;quantization_level&quot;:&quot;Q4_K_M&quot;}}]}</pre><h3>Part 2. Adding Aider</h3><p>Next step was <a href="https://aider.chat/">Aider</a>, because I did not want to just chat with the model in a terminal. The whole point was to see whether the Pi could act as a practical coding assistant.<br>I installed Aider with:</p><pre>curl -LsSf https://aider.chat/install.sh | sh</pre><p>Then I pointed Aider at the local Ollama endpoint:</p><pre>export OLLAMA_HOST=http://&lt;pi-ip&gt;:11434<br>aider --model ollama/qwen2.5-coder:3b</pre><p>As you can see, the setup is very straightforward — Ollama and Aider are both quite approachable. That is actually part of why this whole thing is tempting: you can get from zero to “local coding assistant” surprisingly fast.<br>The friction starts when you want the model to be useful enough that it saves time rather than consumes it.</p><h3>Part 3. Testing it on real tasks</h3><p>That’s where it finally gets interesting. I did not want to benchmark nonsense prompts or ask it trivia questions. I wanted to try small, normal routine developer tasks.</p><h3>Task 1: generate a Go test</h3><p>The first meaningful test was asking it to generate a Go unit test for an existing class or function. This is exactly the sort of thing I imagined a local coding model could help with: something structured, something repetitive, something where “pretty good” is often enough.</p><p>The repo I tried it on was a small Go project, and the target file was just under 300 lines. I tried 1.5B, 3B, and 7B models, and all of them were a total failure — the API timed out after 10 minutes without ever returning an answer. And obviously, waiting that long for a response to a coding request is not assistance — it is an interruption. And that is without even talking about concurrent requests.</p><p>Next, I decided to check whether it was actually capable of generating anything at all.</p><h3>Task 2: a small Python script</h3><p>Next, I decided to make the task much easier. Instead of asking the model to work with an existing Go codebase, I created a very small Python example just for testing:</p><pre>def to_slug(title: str, max_length: int = 50) -&gt; str:<br>    &quot;&quot;&quot;<br>    Convert a title into a URL-friendly slug.<br><br>    Rules:<br>    - lowercase everything<br>    - keep letters, numbers, spaces, hyphens, underscores<br>    - replace spaces/underscores with hyphens<br>    - collapse multiple hyphens<br>    - strip leading/trailing hyphens<br>    - truncate to max_length, then strip trailing hyphens again<br><br>    Raises:<br>    - TypeError if title is not a string<br>    - ValueError if title is empty/blank or max_length &lt; 1<br>    &quot;&quot;&quot;<br>    if not isinstance(title, str):<br>        raise TypeError(&quot;title must be a string&quot;)<br>    if max_length &lt; 1:<br>        raise ValueError(&quot;max_length must be at least 1&quot;)<br><br>    title = title.strip().lower()<br>    if not title:<br>        raise ValueError(&quot;title cannot be empty&quot;)<br><br>    # Remove unsupported characters, but keep spaces, hyphens, underscores.<br>    title = re.sub(r&quot;[^a-z0-9\s\-_]&quot;, &quot;&quot;, title)<br><br>    # Convert spaces/underscores to hyphens.<br>    title = re.sub(r&quot;[\s_]+&quot;, &quot;-&quot;, title)<br><br>    # Collapse repeated hyphens.<br>    title = re.sub(r&quot;-+&quot;, &quot;-&quot;, title)<br><br>    # Trim edges.<br>    title = title.strip(&quot;-&quot;)<br><br>    # Enforce max length.<br>    title = title[:max_length].rstrip(&quot;-&quot;)<br><br>    if not title:<br>        raise ValueError(&quot;title produced an empty slug&quot;)<br><br>    return title</pre><p>This felt like a much more forgiving test. Less context, less structure, less code to produce. If the Pi could not handle a Go test in a small repo, maybe it could at least manage a tiny Python exercise.</p><p>The prompt was:</p><pre>&gt; Write a small pytest test file for string_tools.py. <br>&gt;  <br>&gt; Focus only on the function to_slug. <br>&gt; Cover: <br>&gt; - normal title <br>&gt; - uppercase letters <br>&gt; - multiple spaces <br>&gt; - underscores <br>&gt; - punctuation removal <br>&gt; - repeated hyphens collapsing <br>&gt; - truncation with max_length <br>&gt; - blank input raising ValueError <br>&gt; - non-string input raising TypeError <br>&gt; - max_length &lt; 1 raising ValueError <br>&gt;  <br>&gt; Return only the test file code.                  </pre><h4>qwen2.5-coder:1.5b</h4><p>The 1.5B model took around 4 minutes to generate a test file.<br>It did produce something usable:</p><pre>import pytest<br>from string_tool import to_slug<br><br>def test_to_slug_normal_title():<br>    assert to_slug(&quot;Hello World&quot;) == &quot;hello-world&quot;<br><br>def test_to_slug_uppercase_letters():<br>    assert to_slug(&quot;HELLO WORLD&quot;) == &quot;hello-world&quot;<br><br>def test_to_slug_multiple_spaces():<br>    assert to_slug(&quot;  Hello   World  &quot;) == &quot;hello-world&quot;<br><br>def test_to_slug_underscores():<br>    assert to_slug(&quot;Hello_World&quot;) == &quot;hello-world&quot;<br><br>def test_to_slug_punctuation_removal():<br>    assert to_slug(&quot;Hello, World!&quot;) == &quot;helloworld&quot;<br><br>def test_to_slug_repeated_hyphens_collapsing():<br>    assert to_slug(&quot;Hello---World&quot;) == &quot;hello-world&quot;<br><br>def test_to_slug_truncation_with_max_length():<br>    assert to_slug(&quot;Hello World&quot;, max_length=10) == &quot;hello-worl&quot;<br><br>def test_to_slug_blank_input_raising_ValueError():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;&quot;)<br><br>def test_to_slug_non_string_input_raising_TypeError():<br>    with pytest.raises(TypeError):<br>        to_slug(123)<br><br>def test_to_slug_max_length_less_than_1_raising_ValueError():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;Hello World&quot;, max_length=0)</pre><p>but one of the tests was simply wrong:</p><pre>======================================================================================= FAILURES =======================================================================================<br>___________________________________________________________________________ test_to_slug_punctuation_removal ___________________________________________________________________________<br><br>    def test_to_slug_punctuation_removal():<br>&gt;       assert to_slug(&quot;Hello, World!&quot;) == &quot;helloworld&quot;<br>E       AssertionError: assert &#39;hello-world&#39; == &#39;helloworld&#39;<br>E         - helloworld<br>E         + hello-world<br>E         ?      +<br><br>test_string_tool.py:17: AssertionError<br>=============================================================================== short test summary info ================================================================================<br>FAILED test_string_tool.py::test_to_slug_punctuation_removal - AssertionError: assert &#39;hello-world&#39; == &#39;helloworld&#39;<br><br>============================================================================= 1 failed, 9 passed in 0.09s ==============================================================================</pre><p>This was not a terrible result in absolute terms. The model mostly understood the task. But 4 minutes to produce a small pytest file with a wrong assertion is already far outside what I would consider useful in a real workflow.</p><h4>qwen2.5-coder:3b</h4><p>The 3B model took around 4 and a half minutes. This time, it generated a better test file:</p><pre>import pytest<br>from string_tool import to_slug<br><br><br>def test_to_slug_normal_title():<br>    assert to_slug(&quot;Hello World&quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_uppercase_letters():<br>    assert to_slug(&quot;HELLO WORLD&quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_multiple_spaces():<br>    assert to_slug(&quot;  Hello   World  &quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_underscores():<br>    assert to_slug(&quot;Hello_World&quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_punctuation_removal():<br>    assert to_slug(&quot;Hello, World!&quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_repeated_hyphens_collapsing():<br>    assert to_slug(&quot;Hello---World&quot;) == &quot;hello-world&quot;<br><br><br>def test_to_slug_truncation_with_max_length():<br>    assert to_slug(&quot;Hello World&quot;, max_length=10) == &quot;hello-worl&quot;<br><br><br>def test_to_slug_blank_input_raises_value_error():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;&quot;)<br><br><br>def test_to_slug_non_string_input_raises_type_error():<br>    with pytest.raises(TypeError):<br>        to_slug(123)<br><br><br>def test_to_slug_max_length_less_than_one_raises_value_error():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;Hello World&quot;, max_length=0)</pre><p>All 10 cases passed without modification:</p><pre>10 passed in 0.02s</pre><p>The output was decent, but the latency was still absurd for something this small. I could probably write the test faster myself than wait for the model to finish thinking about it. <br>There was also still room for improvement in the generated output. For example, I would probably add one extra test for an all-whitespace input.</p><pre>with pytest.raises(ValueError):<br>    to_slug(&quot;   &quot;)</pre><h4>qwen2.5-coder:7b</h4><p>The 7B model took more than 10 minutes. Unlike the smaller two, it actually placed the test under test/test_string_tool.py, which at first looked like a smarter result:</p><pre>import pytest<br>from string_tool import to_slug<br><br>def test_normal_title():<br>    assert to_slug(&quot;Hello World&quot;) == &quot;hello-world&quot;<br><br>def test_uppercase_letters():<br>    assert to_slug(&quot;HELLO WORLD&quot;) == &quot;hello-world&quot;<br><br>def test_multiple_spaces():<br>    assert to_slug(&quot;  Hello   World  &quot;) == &quot;hello-world&quot;<br><br>def test_underscores():<br>    assert to_slug(&quot;Hello_World&quot;) == &quot;hello-world&quot;<br><br>def test_punctuation_removal():<br>    assert to_slug(&quot;Hello, World!&quot;) == &quot;hello-world&quot;<br><br>def test_repeated_hyphens_collapsing():<br>    assert to_slug(&quot;Hello--World&quot;) == &quot;hello-world&quot;<br><br>def test_truncation_with_max_length():<br>    assert to_slug(&quot;This is a very long title that should be truncated&quot;, max_length=10) == &quot;this-is-a-very&quot;<br><br>def test_blank_input_raising_ValueError():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;&quot;)<br><br>def test_non_string_input_raising_TypeError():<br>    with pytest.raises(TypeError):<br>        to_slug(123)<br><br>def test_max_length_less_than_1_rasing_ValueError():<br>    with pytest.raises(ValueError):<br>        to_slug(&quot;Hello World&quot;, max_length=0)</pre><p>But the file did not even run:</p><pre>E   ModuleNotFoundError: No module named &#39;string_tool&#39;</pre><p>And after correcting the import issue manually, one test was still failing:</p><pre>======================================================================================= FAILURES =======================================================================================<br>___________________________________________________________________________ test_truncation_with_max_length ____________________________________________________________________________<br><br>    def test_truncation_with_max_length():<br>&gt;       assert to_slug(&quot;This is a very long title that should be truncated&quot;, max_length=10) == &quot;this-is-a-very&quot;<br>E       AssertionError: assert &#39;this-is-a&#39; == &#39;this-is-a-very&#39;<br>E         - this-is-a-very<br>E         ?          -----<br>E         + this-is-a<br><br>test_string_tool.py:23: AssertionError<br>=============================================================================== short test summary info ================================================================================<br>FAILED test_string_tool.py::test_truncation_with_max_length - AssertionError: assert &#39;this-is-a&#39; == &#39;this-is-a-very&#39;<br>============================================================================= 1 failed, 9 passed in 0.09s ==============================================================================</pre><p>The more disappointing part was that the tool did not really know how to complete the loop on its own. It could generate test code, but it was not capable of running the tests, observing the failures, and iteratively fixing the mistakes as part of the same workflow.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/840/1*2lz1Gn8ouAslZvzWPe1rnQ.png" /></figure><p>That matters, because the whole point of a local coding assistant is not just to dump out code. It is to help you get to a working result with minimal friction.</p><p>At that point, the experience was nowhere near something I would actually reach for during normal work. The latency alone made even small tasks feel heavier than they should.</p><h3>Part 4. Where it falls apart</h3><p>In hindsight, it seems obvious that a Raspberry Pi — even the most capable 16 GB model — is not a tool designed for AI coding assistance. That does not mean the Raspberry Pi 5 is bad hardware. On the contrary, it is actually impressive for what it is meant to do.</p><p>But LLM inference, especially for coding, is brutally demanding once you move from toy demos to actual use. Coding models need enough memory, enough throughput, and enough raw inference speed to keep the interaction tight. That is where the Pi falls behind.</p><p>Even when the model technically fits, the experience is bottlenecked by the kind of hardware the Pi is: limited CPU performance compared to real desktops, no serious GPU acceleration for this kind of workload, and memory bandwidth that simply is not designed for fast local inference with coding models.</p><h3>Conclusion</h3><p>My original idea was simple: use a Raspberry Pi 5 as a local coding agent to ease API usage a bit and finally give this expensive little machine a serious AI job.<br>After trying it, my conclusion is also simple: this is not the right machine for that task.</p><p>If your goal is to tinker, learn how local models work, or build small demos, the Pi is still fun. If your goal is to have something that genuinely helps with day-to-day coding, even the 16 GB Raspberry Pi 5 is not enough.</p><p>And honestly, I think that is worth saying out loud, because a lot of hobbyist AI content focuses on the fact that something can be made to run, not whether it should be used that way.</p><p>So my Pi probably has a different future now. Less “local coding agent”, more “smart home box”, Home Assistant host, or some other always-on project that actually fits the hardware.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b44cab060cae" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Running Local LLMs on Raspberry Pi 5 and Hailo AI HAT+ 2]]></title>
            <link>https://pudding-entertainment.medium.com/running-local-llms-on-raspberry-pi-5-and-hailo-ai-hat-2-b999fa240319?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/b999fa240319</guid>
            <category><![CDATA[llm]]></category>
            <category><![CDATA[generative-ai-tools]]></category>
            <category><![CDATA[raspberry-pi]]></category>
            <category><![CDATA[hailo]]></category>
            <category><![CDATA[ai]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 08 Mar 2026 13:49:33 GMT</pubDate>
            <atom:updated>2026-03-08T13:49:33.404Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*aL7xL3yD8YjSyehDCIE_lg.png" /><figcaption>Image generated by ChatGPT 5.4</figcaption></figure><p>Running large language models locally used to mean a power-hungry desktop GPU and a fair amount of patience. With the Raspberry Pi 5 and the Raspberry Pi AI HAT+ 2 (Hailo-10H), that barrier is much lower. You can build a compact, low-power system that runs LLM inference entirely on your desk — or, in my case, on a TV stand.</p><p>This guide walks through setting up a fully local LLM environment on a Raspberry Pi 5 using Ubuntu Server 24.04, the Raspberry Pi AI HAT+ 2, Hailo’s Ollama-compatible runtime, and OpenWebUI. By the end, you’ll have a persistent system service that starts automatically at boot and exposes a browser-based chat interface powered completely by local hardware.<br>The goal isn’t to compete with data-center GPUs. It’s to build a practical, private, always-on AI node that you control — something you can integrate into home automation, development workflows, or offline experimentation without relying on external infrastructure.</p><p>In the final setup, the architecture looks like this:</p><pre>Browser<br>  ↓<br>OpenWebUI (systemd service, port 8080)<br>  ↓<br>hailo-ollama (systemd service, port 8000)<br>  ↓<br>/dev/hailo0<br>  ↓<br>Hailo-10H hardware accelerator</pre><p>The main idea is to keep the stack native and persistent: no Docker, no manual launch steps after reboot, and as few moving parts as possible.</p><h3>From “Build It From Scratch” to “Make It Stable”</h3><p>The original plan was straightforward: run a fully local LLM on a Raspberry Pi 5 with Ubuntu and the AI HAT+ 2 using only native Linux components.<br>So I started the way many of us would: build everything manually.<br>That meant compiling the Hailo kernel driver against the running kernel, building the runtime libraries, and wiring the stack together piece by piece. In theory, this gives maximum control. In practice, it turned into a maintenance loop. Kernel module builds were sensitive to small changes — compiler versions, header mismatches, minor kernel updates. Fix one issue, hit the next. Even after the device showed up correctly as /dev/hailo0, the userspace runtime had to match the hardware generation exactly. Some versions detected the device but couldn’t communicate with it properly. Others simply weren’t packaged for Ubuntu yet.</p><p>At that point, the question wasn’t “Can this work?” — it clearly could.<br>The question was “Will this stay working?”</p><p>Instead of continuing to rebuild everything manually, I switched to using the Raspberry Pi repositories for the Hailo driver and runtime packages. Initially that felt like a compromise, since the base system is Ubuntu. But by limiting it strictly to the Hailo components, compatibility issues never surfaced. The difference was immediate:</p><ul><li>The kernel module loaded cleanly</li><li>The runtime matched the hardware</li><li>Models initialized straight away</li><li>The inference server stayed stable across reboots</li></ul><p>Building from source gives control, but using vendor-maintained packages gives stability. For this project, stability won — and that’s what turned it from an experiment into a reliable, always-on local AI node.</p><p>Next, let’s go over the installation steps one by one.</p><blockquote><strong>Note! </strong>At the time of writing, Raspberry Pi’s official LLM setup guide for the AI HAT+ 2 references <a href="https://www.raspberrypi.com/documentation/computers/ai.html#step1-llm"><strong>version 5.1.1</strong></a> of the Hailo GenAI Model Zoo package. I used that same version here to stay aligned with the vendor documentation. Besides that, version 5.2.0 wasn’t available in the official repo anyways.</blockquote><h4>Step 1. Install the Hailo Kernel Driver</h4><p>The first step is getting the kernel-level pieces in place so Ubuntu can talk to the accelerator correctly. Since the required Hailo packages were available through the Raspberry Pi package repository, I added that repository and pinned it so only the Hailo-related packages would be pulled from there:</p><pre>curl -fsSL https://archive.raspberrypi.com/debian/raspberrypi.gpg.key \<br>| sudo gpg --dearmor -o /usr/share/keyrings/raspberrypi-archive-keyring.gpg<br><br>echo &quot;deb [arch=arm64 signed-by=/usr/share/keyrings/raspberrypi-archive-keyring.gpg] \<br>http://archive.raspberrypi.com/debian trixie main&quot; \<br>| sudo tee /etc/apt/sources.list.d/raspberrypi.list</pre><p>Then create an APT pinning file so Ubuntu does not start preferring Raspberry Pi packages for unrelated components:</p><pre>nano /etc/apt/preferences.d/raspberrypi-pin<br><br>Package: *<br>Pin: origin archive.raspberrypi.com<br>Pin-Priority: 1<br><br>Package: h10-hailort-pcie-driver hailort hailort-* libhailort* hailo*<br>Pin: origin archive.raspberrypi.com<br>Pin-Priority: 1001</pre><p>With that in place, install the kernel driver and matching kernel headers:</p><pre>sudo apt update<br>sudo apt install h10-hailort-pcie-driver linux-headers-$(uname -r)</pre><p>To verify that the device is visible to the system, check for the Hailo device node:</p><pre>ls -l /dev/hailo*<br><br>crw-rw-rw- 1 root root 234, 0 Feb 24 23:00 /dev/hailo0</pre><p>If /dev/hailo0 is present, the kernel side of the setup is in good shape.</p><h4>Step 2. Install the Hailo Runtime</h4><p>Once the driver is loaded and the device is exposed, the next layer is the Hailo runtime itself. This is what allows user-space applications to communicate with the accelerator properly. Install the Hailo-10H runtime:</p><pre>sudo apt install h10-hailort</pre><p>Then verify that the runtime can talk to the hardware:</p><pre>hailortcli fw-control identify</pre><p>The output should include something along the lines of:</p><pre>Firmware Version: 5.1.1<br>Device Architecture: HAILO10H</pre><p>This is an important checkpoint. At this stage, the hardware is not just detected by Linux — it is actually responding correctly through the runtime stack.</p><h4>Step 3. Install the Hailo Ollama Runtime</h4><p>With the low-level pieces working, the next step is the inference layer. In this setup, that role is handled by hailo-ollama, which exposes an Ollama-compatible API server. Install the model zoo package:</p><pre>wget https://dev-public.hailo.ai/2025_12/Hailo10/hailo_gen_ai_model_zoo_5.1.1_arm64.deb<br>sudo dpkg -i hailo_gen_ai_model_zoo_*.deb</pre><p>After installation, verify that the API is responding:</p><pre>curl http://localhost:8000/hailo/v1/list</pre><p>If everything is working, you should get a JSON response listing the available models:</p><pre>{&quot;models&quot;:[...]}</pre><p>At that point, the backend inference service is up and ready.</p><h4>Step 4. Install and configure OpenWebUI</h4><p>For the frontend, I wanted a simple web interface without introducing containers, so OpenWebUI was installed directly into its own Python virtual environment.</p><p>First, create a dedicated service user:</p><pre>sudo useradd -r -m -d /opt/openwebui -s /usr/sbin/nologin openwebui</pre><p>Then switch to that user and install OpenWebUI inside a virtual environment:</p><pre>sudo -u openwebui bash<br>cd /opt/openwebui<br>python3 -m venv venv<br>source venv/bin/activate<br>pip install open-webui<br>exit</pre><p>Keeping OpenWebUI under its own system user makes the setup cleaner and easier to manage later.</p><p>Next, create a small environment file for OpenWebUI:</p><pre>nano /opt/openwebui/env<br><br>OLLAMA_BASE_URL=http://localhost:8000<br>WEBUI_AUTH=False</pre><p>The important part here is OLLAMA_BASE_URL=http://localhost:8000, which points OpenWebUI at the local hailo-ollama service.</p><h4>Step 5. Create a systemd Service for Hailo Ollama</h4><p>The inference service also needs to start automatically, so create a systemd unit:</p><pre>nano /etc/systemd/system/hailo-ollama.service<br><br>[Unit]<br>Description=Hailo Ollama Service<br>After=network.target<br><br>[Service]<br>Type=simple<br>ExecStart=/usr/bin/hailo-ollama serve<br>Restart=always<br><br>[Install]<br>WantedBy=multi-user.target</pre><p>Enable and start it:</p><pre>sudo systemctl daemon-reload<br>sudo systemctl enable hailo-ollama<br>sudo systemctl start hailo-ollama</pre><h4>Step 6. Create a systemd Service for OpenWebUI</h4><p>To make the web interface persistent across reboots, create a systemd service:</p><pre>nano /etc/systemd/system/openwebui.service<br><br>[Unit]<br>Description=OpenWebUI Service<br>After=network.target hailo-ollama.service<br>Wants=hailo-ollama.service<br><br>[Service]<br>Type=simple<br>WorkingDirectory=/opt/openwebui<br>EnvironmentFile=/opt/openwebui/env<br>ExecStart=/opt/openwebui/venv/bin/open-webui serve --host 0.0.0.0 --port 8080<br><br>Restart=always<br>RestartSec=3<br><br>[Install]<br>WantedBy=multi-user.target</pre><p>Then reload systemd, enable the service, and start it:</p><pre>sudo systemctl daemon-reload<br>sudo systemctl enable openwebui<br>sudo systemctl start openwebui</pre><p>With both services enabled, the system comes back up cleanly after a reboot and restores the full local LLM stack automatically.</p><h4>Step 7. Access the Web UI</h4><p>Finally, once both services are running, open a browser and connect to:</p><pre>http://&lt;raspberry-pi-ip&gt;:8080</pre><p>If the backend is reachable, the models should appear automatically in OpenWebUI and the system is ready to use.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*2t0PY8bEexA-sbamfEZM9g.png" /></figure><h3>Final Thoughts</h3><p>The Raspberry Pi 5 and AI HAT+ 2 can absolutely be turned into a fully local LLM appliance, and that alone is impressive. But getting there is still more about careful integration than plug-and-play simplicity. Once the stack is stable, it is neat, quiet, and self-contained — but the performance is still limited, with relatively slow generation and a narrow practical model range. So, don’t expect it to be your “ChatGPT replacement” quite yet.</p><p>The bigger issue is software support — especially outside of Raspberry Pi OS. Raspberry Pi launched the AI HAT+ 2 in January 2026, yet its official documentation still references GenAI package version 5.1.1, while Hailo’s own ecosystem already points to 5.2.0 support for Hailo-10H. For a product aimed at local AI on the Pi, that lag is hard to ignore. Especially, combined with the fact that some of the models were replaced — most notably <a href="https://community.hailo.ai/t/llama-3-2-3b-instruct-model/18866">Llama-3.2–3B-Instruct model</a>. It does not break the project per se, but it does make the platform feel immature — and it makes the buying decision harder to justify unless you specifically enjoy working through rough edges. For hobbyists who like experimenting, it is interesting. For anyone expecting a polished out-of-the-box local LLM platform, it is probably not there yet.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=b999fa240319" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Java: Building a Slack Bot]]></title>
            <link>https://pudding-entertainment.medium.com/java-building-slack-bot-475be6bb0b22?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/475be6bb0b22</guid>
            <category><![CDATA[spring-boot]]></category>
            <category><![CDATA[software-development]]></category>
            <category><![CDATA[slack]]></category>
            <category><![CDATA[slackbot]]></category>
            <category><![CDATA[java]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 09 Nov 2025 19:48:04 GMT</pubDate>
            <atom:updated>2025-11-09T19:53:58.238Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*iugATBY_vY5SAj7sjRXODQ.png" /><figcaption>Image generated by ChatGPT</figcaption></figure><p>Working on an enterprise-level application, every team eventually faces a familiar challenge — users having doubts or questions about how certain parts of the system work. Answering those can quickly become a time-consuming, context-switching task for developers.</p><p>To make that process smoother at the company I’m with right now, I decided to build a support request Slack bot tailored to our needs. What I first thought would be a quick weekend project turned into a two-month journey.</p><p>In this article, I’ll share some lessons learned, tips and tricks, and parts of the implementation to help fellow developers build their own bots faster and more efficiently.</p><h4>Part 1. Requirements</h4><p>To set the right context, here’s what functionality the support bot should handle:</p><ul><li>A user goes to a dedicated Slack channel and starts a <strong>Workflow</strong> via, as we call it, “Big green button” that appears when you feature a given workflow in a channel</li><li>A form appears with a set of fields — most importantly, the <strong>description of the request</strong> and the <strong>team</strong> that should take a look at it</li><li>Upon submission, a message with the provided description, team, and a set of action buttons (<strong>Claim</strong>, <strong>Take Over</strong>, <strong>Resolve</strong>, and <strong>Create Jira Ticket</strong>) should be posted</li><li>Someone from the team picks it up, works on the request, and eventually closes it</li></ul><p>As with any KPI-driven organisation, we also wanted to collect metrics — such as how many requests come in each month and how long they take to resolve.</p><p>They say a picture is worth a thousand words, so here’s what the final flow looks like:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/791/1*_Soq8J563aWjuCcQzNwO3A.png" /><figcaption>Featured workflow in a channel</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/527/1*3NvOm1PAqsrmvrFVDyiBZA.png" /><figcaption>The form</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/510/1*cFL82G8xD0bBMRGUAl3aRw.png" /><figcaption>The message sent upon submission</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/495/1*hu2CkhSZkJcBsHTQug1kNw.png" /><figcaption>The message after someone claims it</figcaption></figure><figure><img alt="" src="https://cdn-images-1.medium.com/max/468/1*BScsAnE4n11WH1LnMtpIFg.png" /><figcaption>The final resolved state</figcaption></figure><p>As you can see, the core requirements boil down to just two things: <strong>interactivity</strong> and <strong>simplicity</strong>.</p><p>In the next part, we’ll move from <em>what</em> the bot should do to <em>how</em> it’s built — exploring the architecture, integration options, and the deployment setup.</p><h4>Part 2. Architecture</h4><p>There are two ways to host a Slack bot — <strong>directly on Slack</strong> or <strong>self-hosted</strong> on your own infrastructure. That choice greatly affects how the bot will be built. Based on our requirements, the obvious decision was to self-host it. Building TypeScript-based workflows directly on Slack has certain limitations that just weren’t acceptable for what we needed.</p><p>When it comes to implementation, Slack provides <a href="https://docs.slack.dev/tools">several tools</a> — you can choose from <strong>Java</strong>, <strong>JavaScript</strong>, or <strong>Python</strong> via their <strong>Bolt frameworks</strong> or lower-level <strong>API SDKs</strong>. Generally speaking:</p><ul><li><strong>Bolt</strong> is the better choice when you want a clean abstraction of Slack-related logic.</li><li><strong>Slack API SDKs</strong> should only be considered if you’re building something highly customized or more complex.</li></ul><p>Next, there are <a href="https://docs.slack.dev/apis/events-api/comparing-http-socket-mode">two integration modes</a> you can use — <strong>Socket Mode</strong> or <strong>HTTP</strong>.<br>I won’t go too deep into the differences here (the official docs explain them well), but from experience: go with <strong>HTTP</strong> for production.<br>Socket Mode is fine for quick tests or prototypes, but it becomes painful when you try to make it production-ready. Save yourself the trouble unless you’re building something that will never leave the prototype stage.</p><p>Then comes another confusing part — <strong>how your bot is used in Slack</strong>. You can expose it through <strong>Slash Commands</strong>, <strong>Shortcuts</strong>, or <strong>Workflow Steps</strong>.<br>Here’s the catch: Slack Workflows can only use Workflow Steps.<br>There’s no way to wire Slash Commands or Shortcuts into them.<br>I initially assumed Slash Commands would be the most straightforward integration — big mistake. I ended up rewriting about 40% of the app.<br><strong>Lesson learned:</strong> if you’re building for Slack Workflows, stick to Workflow Steps only:</p><pre>app.function(&quot;support-request&quot;, (req, ctx) -&gt; ctx.ack());</pre><p>With all that in mind, for our requirements I went with:</p><ul><li>Java 25</li><li>Bolt for Java</li><li>Spring Boot</li><li>Hosted on Google Cloud Run</li><li>Connected to Slack via HTTP, integrated as a Workflow Step</li></ul><h4>Part 3. Implementation</h4><p>Unlike most of my tutorials here, I won’t be sharing a complete GitHub repo — instead, I’ll focus on the <strong>key pieces of the implementation</strong>, with a few code snippets and hints along the way. I’ll also skip the “Create Jira Ticket” logic to keep things focused on the Slack side.</p><p>Let’s start with the <strong>app configuration</strong>. You’ll need two secret values:</p><ul><li><strong>Bot User OAuth Token</strong> — found on the <em>Install App</em> page</li><li><strong>Signing Secret</strong> — found on the <em>Basic Information</em> page</li></ul><p>With those two, you can create a simple Spring configuration class that wires up the Slack Bolt app, request handler, verifier, and parser beans:</p><pre>@Configuration<br>public class AppConfiguration {<br><br>    @Bean<br>    public App slackBoltApp(final RequestHandler requestHandler,<br>                            @Value(&quot;${SLACK_BOT_TOKEN:}&quot;) final String botToken,<br>                            @Value(&quot;${SLACK_SIGNING_SECRET:}&quot;) final String signingSecret) {<br>        final var appConfig = buildAppConfig(botToken, signingSecret);<br>        final var app = new App(appConfig);<br>        requestHandler.registerHandlers(app);<br>        return app;<br>    }<br><br>    private AppConfig buildAppConfig(final String botToken,<br>                                     final String signingSecret) {<br>        final var config = new AppConfig();<br>        config.setSingleTeamBotToken(botToken);<br>        config.setSigningSecret(signingSecret);<br>        config.setRequestVerificationEnabled(true);<br>        config.setAttachingFunctionTokenEnabled(false);<br>        return config;<br>    }<br><br>    @Bean<br>    public SlackSignature.Verifier signatureVerifier(final App app) {<br>        final var signingSecret = app.config().getSigningSecret();<br>        final var generator = new SlackSignature.Generator(signingSecret);<br>        return new SlackSignature.Verifier(generator);<br>    }<br><br>    @Bean<br>    public SlackRequestParser requestParser(final App app) {<br>        return new SlackRequestParser(app.config());<br>    }<br>}</pre><blockquote>config.setAttachingFunctionTokenEnabled(false) part will be explained later — just keep it in mind for now.</blockquote><p>Once the configuration is in place, the next step is implementing a <strong>controller</strong> to handle incoming Slack requests. This controller reads the request body, verifies the signature (to confirm it’s from Slack), and then passes it into app.run() for processing:</p><pre>@RestController<br>public class SlackEventsController {<br>    private final App app;<br>    private final SlackRequestParser requestParser;<br>    private final SlackSignature.Verifier signatureVerifier;<br><br>    @PostMapping(&quot;/slack/events&quot;)<br>    public ResponseEntity&lt;String&gt; handleSlackEvents(final HttpServletRequest request) {<br>        final byte[] bodyBytes;<br>        try {<br>            bodyBytes = request.getInputStream().readAllBytes();<br>        } catch (final IOException e) {<br>            log.error(&quot;Failed to read Slack request body&quot;, e);<br>            return ResponseEntity.internalServerError().body(&quot;Unable to read request&quot;);<br>        }<br>        final var body = new String(bodyBytes, StandardCharsets.UTF_8);<br>        final var timestamp = request.getHeader(SlackSignature.HeaderNames.X_SLACK_REQUEST_TIMESTAMP);<br>        final var signature = request.getHeader(SlackSignature.HeaderNames.X_SLACK_SIGNATURE);<br><br>        if (!signatureVerifier.isValid(timestamp, body, signature)) {<br>            log.warn(&quot;Rejected Slack request due to invalid signature or timestamp. timestamp={} signature={} bodyLength={}&quot;,<br>                     timestamp,<br>                     signature,<br>                     body.length());<br>            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(&quot;Invalid request signature&quot;);<br>        }<br><br>        final var slackRequest = requestParser.parse(buildHttpRequest(request, body));<br>        if (slackRequest == null) {<br>            log.warn(&quot;Unable to parse Slack request for uri={} body={}&quot;, request.getRequestURI(), body);<br>            return ResponseEntity.badRequest().body(&quot;Unrecognized Slack request&quot;);<br>        }<br><br>        try {<br>            final var slackResponse = app.run(slackRequest);<br>            return toResponseEntity(slackResponse);<br>        } catch (final Exception e) {<br>            log.error(&quot;Failed to process Slack request&quot;, e);<br>            return ResponseEntity.internalServerError().body(&quot;Something went wrong&quot;);<br>        }<br>    }<br><br>    private SlackRequestParser.HttpRequest buildHttpRequest(final HttpServletRequest request,<br>                                                            final String body) {<br>        return SlackRequestParser.HttpRequest.builder()<br>                                             .requestUri(request.getRequestURI())<br>                                             .requestBody(body)<br>                                             .headers(extractHeaders(request))<br>                                             .remoteAddress(request.getRemoteAddr())<br>                                             .socketMode(false)<br>                                             .build();<br>    }<br><br>    private RequestHeaders extractHeaders(final HttpServletRequest request) {<br>        final Map&lt;String, List&lt;String&gt;&gt; headers = new HashMap&lt;&gt;();<br>        final var headerNames = request.getHeaderNames();<br>        while (headerNames.hasMoreElements()) {<br>            final var name = headerNames.nextElement();<br>            headers.put(name, Collections.list(request.getHeaders(name)));<br>        }<br>        return new RequestHeaders(headers);<br>    }<br><br>    private ResponseEntity&lt;String&gt; toResponseEntity(final Response slackResponse) {<br>        final var statusCode = slackResponse.getStatusCode() != null<br>                ? HttpStatus.valueOf(slackResponse.getStatusCode())<br>                : HttpStatus.OK;<br>        final var headers = new HttpHeaders();<br>        slackResponse.getHeaders().forEach((name, values) -&gt; values.forEach(value -&gt; headers.add(name, value)));<br>        if (slackResponse.getContentType() != null) {<br>            headers.set(HttpHeaders.CONTENT_TYPE, slackResponse.getContentType());<br>        }<br>        return new ResponseEntity&lt;&gt;(slackResponse.getBody(), headers, statusCode);<br>    }<br>}</pre><p>In short, what’s happening here is:</p><ol><li>The raw HTTP request is read and validated using Slack’s signature verifier</li><li>The body is parsed into a structured Slack request using SlackRequestParser</li><li>The verified request is handed off to the app for handling via app.run()</li><li>The response from Slack Bolt is then mapped back to a standard ResponseEntity and returned</li></ol><p>This setup handles all the foundational plumbing you’ll need — request validation, parsing, and forwarding to your core logic — while staying lightweight and fully compatible with Spring Boot.</p><p>With the bot setup out of the way, we can finally start building the actual logic and model. Thankfully, Slack provides a solid enough abstraction to make this part relatively straightforward:</p><pre>@Service<br>public class ModalRenderer {<br><br>    public View renderModal(final String supportModalCallbackId,<br>                            final String channelId) {<br>        final var blocks = new ArrayList&lt;LayoutBlock&gt;();<br>        blocks.add(Blocks.input(input -&gt; input<br>                .blockId(DESCRIPTION_BLOCK_ID)<br>                .label(PlainTextObject.builder().text(&quot;Describe the request&quot;).build())<br>                .element(RichTextInputElement.builder()<br>                                             .actionId(DESCRIPTION_ACTION_ID)<br>                                             .placeholder(PlainTextObject.builder().text(&quot;What do you need help with?&quot;).build())<br>                                             .focusOnLoad(true)<br>                                             .build())<br>                .optional(false)<br>        ));<br><br>        blocks.add(Blocks.input(input -&gt; input<br>                .blockId(TEAM_BLOCK_ID)<br>                .label(PlainTextObject.builder().text(&quot;Which team should look at this?&quot;).build())<br>                .element(StaticSelectElement.builder()<br>                                            .actionId(TEAM_ACTION_ID)<br>                                            .options(Arrays.stream(Team.values())<br>                                                           .map(team -&gt; OptionObject.builder()<br>                                                                                    .text(PlainTextObject.builder()<br>                                                                                                         .text(team.getMention())<br>                                                                                                         .build())<br>                                                                                    .value(team.getMention())<br>                                                                                    .build())<br>                                                           .toList())<br>                                            .build())<br>        ));<br><br>        ...<br><br>        return View.builder()<br>                   .type(&quot;modal&quot;)<br>                   .callbackId(supportModalCallbackId)<br>                   .title(ViewTitle.builder().type(&quot;plain_text&quot;).text(&quot;Support request&quot;).build())<br>                   .submit(ViewSubmit.builder().type(&quot;plain_text&quot;).text(&quot;Submit&quot;).build())<br>                   .close(ViewClose.builder().type(&quot;plain_text&quot;).text(&quot;Cancel&quot;).build())<br>                   .privateMetadata(channelId)<br>                   .blocks(blocks)<br>                   .build();<br>    }<br>}</pre><p>The ModalRenderer service is responsible for rendering the <strong>support request form</strong> — essentially a modal view with a few input fields. Most of the code here is self-explanatory, but I’ll highlight two key parameters being passed in:</p><ul><li>supportModalCallbackId — kept private to the class that manages the main flow</li><li>channelId — stored in privateMetadata so it can be accessed later by the class responsible for posting the final support message to the correct channel</li></ul><p>Now that we have a form to render, the next question is: how do we actually show it to the user?<strong> </strong>From our earlier requirements, we know we want to integrate with Slack Workflows. To do that, you first need to add a new <strong>Workflow Step</strong> on Slack’s side:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/706/1*-m67NYfw2VfWLJdE3tCYSQ.png" /></figure><p>On the Java side, you can define it with app.function(...). However — and here comes the first <em>architectural surprise</em> — a Workflow can’t trigger a modal directly. Only a <strong>real user action</strong> can open one.</p><blockquote>While working on this bot, I ran into quite a few “interesting” design decisions from Slack — some made sense, others… not so much.<br>Sometimes I could work around them; other times, I had to redesign entire parts of the app.<br>I even opened an <a href="https://github.com/slackapi/java-slack-sdk/issues/1528">issue on GitHub</a> to clarify a few things, but no reply so far.</blockquote><p>After a lot of trial and error, I settled on a two-step implementation that worked reliably:</p><ol><li>User starts the workflow</li><li>The bot sends an ephemeral message (visible only to the user) with a button to open the support request form</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/535/1*6DNvmKaCTyXEWnRGUmgANg.png" /><figcaption>A bit inconvenient — yes. But it works!</figcaption></figure><p>In code, this means setting up three main handlers:</p><ul><li>app.function — the main entry point that sends the ephemeral message with the “Open Form” button</li><li>app.blockAction — triggered when the user clicks that button; this opens the modal form</li><li>app.viewSubmission — fired when the form is submitted; this takes the input and posts a support request message in the target channel</li></ul><pre>@Service<br>public class RequestHandler {<br>    private static final String SUPPORT_MODAL_CALLBACK_ID = &quot;support-request-modal&quot;;<br><br>    ...<br><br>    public void registerHandlers(final App app) {<br>        app.function(&quot;support-request&quot;, (req, ctx) -&gt; {<br>            try {<br>                // inputs provided from Workflow builder<br>                final var inputs = req.getEvent().getInputs();<br>                final var channelId = inputs.get(&quot;channel_id&quot;).getStringValue();<br>                final var userId = inputs.get(&quot;user_id&quot;).getStringValue();<br>                ctx.client().chatPostEphemeral(r -&gt; r<br>                        .channel(channelId)<br>                        .user(userId)<br>                        .text(&quot;Click below to open the support request form.&quot;)<br>                        .blocks(modalRenderer.renderEphemeralMessage(channelId, userId)));<br>            } catch (final IOException | SlackApiException e) {<br>                log.error(&quot;Error opening support request modal&quot;, e);<br>            }<br>            return ctx.ack();<br>        });<br><br>        app.blockAction(Action.SUPPORT.name(), (req, ctx) -&gt; {<br>            try {<br>                final var channelId = req.getPayload().getActions().getFirst().getValue();<br>                final var triggerId = req.getPayload().getTriggerId();<br><br>                final var response = ctx.client().viewsOpen(r -&gt; r<br>                        .triggerId(triggerId)<br>                        .view(modalRenderer.renderModal(SUPPORT_MODAL_CALLBACK_ID, channelId)));<br>                if (!response.isOk()) {<br>                    log.warn(&quot;Failed to open support request modal: {}&quot;, response.getError());<br>                }<br>                ctx.respond(r -&gt; r.deleteOriginal(true));<br>            } catch (final IOException | SlackApiException e) {<br>                log.error(&quot;Error opening support request modal&quot;, e);<br>            }<br>            return ctx.ack();<br>        });<br><br>        app.viewSubmission(SUPPORT_MODAL_CALLBACK_ID, (req, ctx) -&gt; {<br>            try {<br>                final var input = modalRenderer.parseSubmission(req.getPayload().getView().getState());<br>                final var userId = req.getPayload().getUser().getId();<br>                final var channelId = req.getPayload().getView().getPrivateMetadata();<br>                final var messageRequest = ChatPostMessageRequest.builder()<br>                                                                 .channel(channelId)<br>                                                                 .blocks(messageRenderer.renderBlocks(input, userId))<br>                                                                 .build();<br>                final var response = ctx.client().chatPostMessage(messageRequest);<br>                if (!response.isOk()) {<br>                    log.error(&quot;Failed to post support request message: {}&quot;, response.getError());<br>                    throw new IOException(response.getError());<br>                }<br>                return ctx.ack();<br>            } catch (final IOException | SlackApiException e) {<br>                log.error(&quot;Failed to create support request&quot;, e);<br>                return ctx.ackWithErrors(Map.of(SUPPORT_MODAL_CALLBACK_ID, &quot;We hit an error while posting the request. Please try again.&quot;));<br>            }<br>        });<br>}</pre><p>Remember that small line from earlier?</p><pre>config.setAttachingFunctionTokenEnabled(false);</pre><p>Here’s why it matters: By default, Slack uses a <strong>Workflow Function Token</strong>, which doesn’t always have permission to send messages or open modals.<br>Disabling that option ensures your bot uses the <strong>Bot Token</strong> instead — which has the correct permissions for these actions. Without it, your bot might fail to post messages or open forms, depending on the setup.</p><h4>Afterwards</h4><p>I hope this tutorial helps you kickstart your journey into Slack bot development. There’s still a lot more that could be covered — deployment pipelines, testing, persistence layer, action handling, and so on — but I wanted to keep this tutorial focused and practical rather than overwhelming.</p><p>What I really learned while building this bot is that Slack’s developer experience is powerful but quirky. Some design decisions make total sense once you understand the ecosystem; others you’ll just have to work around. But that’s what makes it fun — every challenge is an opportunity to learn something new (and maybe curse a little under your breath).</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=475be6bb0b22" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[GitHub Actions: Spring Boot Application Build and Deployment]]></title>
            <link>https://pudding-entertainment.medium.com/github-actions-spring-boot-application-build-and-deployment-c52a2a233cb9?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/c52a2a233cb9</guid>
            <category><![CDATA[github-actions]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[gitops]]></category>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <category><![CDATA[devops]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sat, 17 May 2025 18:48:50 GMT</pubDate>
            <atom:updated>2025-05-17T18:48:50.969Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*qtmV73pFw9HqE8BuIK_afQ.png" /><figcaption>Generated by ChatGPT</figcaption></figure><p>Continuous Integration and Continuous Deployment (CI/CD) have become foundational pillars of modern software development, enabling teams to ship code faster, safer, and more reliably. While Jenkins has long been the go-to solution, earning its place as a powerful, customizable, and battle-tested tool, the CI/CD landscape has evolved.</p><p>Today, cloud-native development demands more seamless integration and maintainability than traditional server-managed tools can provide. That’s where GitHub Actions comes into play — a modern CI/CD platform built directly into the version control system that has been quietly taking the DevOps world by storm. Its simplicity, combined with a vast ecosystem of reusable actions, makes builds and releases incredibly easy to implement, and even easier to maintain.</p><p>In this article, we’ll develop CI and release workflows using GitHub Actions for a traditional Spring Boot Java application running on Kubernetes.</p><h3>Part 1. Project Setup and CI Action</h3><p>For the sake of this tutorial, the most basic Spring Boot project was created using <a href="https://start.spring.io/">Spring Initializr</a>. It doesn’t matter which build tool is used — Gradle would work just as well (you’d just replace mvn with the relevant gradle command).</p><p>Next, let’s create the CI workflow. You typically place it in the root of your project inside the .github/workflows folder. Here’s what the ci.yml file would look like:</p><pre>name: CI<br><br>on: push<br><br>jobs:<br>  test:<br>    runs-on: ubuntu-latest<br><br>    steps:<br>      - name: Checkout code<br>        uses: actions/checkout@v4<br><br>      - name: Set up JDK<br>        uses: actions/setup-java@v4<br>        with:<br>          java-version: &#39;24&#39;<br>          distribution: &#39;corretto&#39;<br><br>      - name: Cache Maven dependencies<br>        uses: actions/cache@v4<br>        with:<br>          path: ~/.m2/repository<br>          key: ${{ runner.os }}-maven-${{ hashFiles(&#39;**/pom.xml&#39;) }}<br>          restore-keys: |<br>            ${{ runner.os }}-maven-<br><br>      - name: Build with Maven<br>        run: mvn --batch-mode clean test</pre><p>Short and concise, isn’t it?</p><p>While this workflow is mostly self-explanatory, let’s highlight a few key details:</p><ul><li>actions/checkout@v4: This checks out your code from the GitHub repository so it can be built and tested. It’s almost always the first step in any workflow.</li><li>actions/setup-java@v4: This configures the correct version of the JDK. Here we use Amazon Corretto 24, but you can swap in any other supported distribution or version.</li><li>actions/cache@v4: Caching ~/.m2/repository speeds up builds by avoiding repeated downloads of Maven dependencies. The cache key uses a hash of all pom.xml files, ensuring updates when dependencies change.</li><li>mvn — batch-mode clean test: A clean, quiet Maven build that compiles your code and runs all unit tests. You can later extend this step with more complex goals or reporting tools.</li></ul><p>It’s also worth noting that each step, like checkout, setup-java, or cache, is powered by a reusable GitHub Action, often open-sourced and versioned. These actions are typically written in JavaScript or TypeScript and can be customized or replaced if your project has specific requirements. If you ever hit a limitation, writing your own action is straightforward and well-documented.</p><h3>Part 2. Release Action</h3><p>With the CI pipeline in place, let’s move on to the next stage of the DevOps lifecycle: <strong>release</strong>. In this example, we’ll implement a rather simple release pipeline that deploys a single-instance Spring Boot service. While minimal, this pattern works perfectly well in microservices architectures, where each service is independently built, versioned, and deployed.</p><blockquote>One principle worth emphasizing: <strong>start simple, then scale</strong>. Just because the industry pushes for multi-stage releases, Helm charts, or GitOps doesn’t mean your project needs them right away. A basic pipeline done right will take you far — complexity can always come later if your application demands it.</blockquote><p>Here’s a GitHub Actions workflow for building and releasing a Docker image, versioning it, and deploying it to Kubernetes:</p><pre>name: Release<br><br>on:<br>  pull_request:<br>    types: [ closed ]<br><br>env:<br>  # full docker image name without version<br>  IMAGE_NAME: us-docker.pkg.dev/project/repo/demo<br><br>jobs:<br>  release:<br>    if: github.event.pull_request.merged == true &amp;&amp; github.base_ref == &#39;main&#39;<br>    runs-on: ubuntu-latest<br><br>    steps:<br>      - name: Checkout code<br>        uses: actions/checkout@v4<br>        with:<br>          # required for push back to main<br>          ssh-key: ${{ secrets.DEPLOY_KEY }}<br><br>      - name: Set up Java<br>        uses: actions/setup-java@v4<br>        with:<br>          java-version: &#39;24&#39;<br>          distribution: &#39;corretto&#39;<br><br>      - name: Get current version<br>        id: get_version<br>        run: echo &quot;::set-output name=current_version::$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)&quot;<br><br>      - name: Build Spring Boot Docker image and push it<br>        run: |<br>          mvn --batch-mode spring-boot:build-image -DskipTests -Dspring-boot.build-image.imageName=$IMAGE_NAME:${{ steps.get_version.outputs.current_version }}<br>          gcloud auth configure-docker us-docker.pkg.dev --quiet<br>          docker push $IMAGE_NAME:${{ steps.get_version.outputs.current_version }}<br><br>      - name: Bump version<br>        run: |<br>          NEXT_VERSION=$((${{ steps.get_version.outputs.current_version }} + 1))<br>          mvn versions:set -DnewVersion=$NEXT_VERSION -DgenerateBackupPoms=false<br><br>      - name: Commit version<br>        run: |<br>          git config user.name &quot;github-actions[bot]&quot;<br>          git config user.email &quot;github-actions[bot]@users.noreply.github.com&quot;<br>          git commit -am &quot;Release: ${{ steps.get_version.outputs.current_version }}&quot;<br>          git push origin main<br><br>      # Don&#39;t forget to auth before applying<br>      - name: Deploy to k8s<br>        working-directory: deployment<br>        run: |<br>          sed -i &quot;s|{{IMAGE_TAG}}|${{ steps.get_version.outputs.current_version }}|g&quot; prod/deployment.yaml<br>          kubectl apply -f deployment.yaml</pre><p>Let’s highlight a few key details of this workflow as well:</p><ul><li><strong>Triggering on Merged PRs:</strong> This workflow is triggered when a pull request is closed. The if condition ensures it only runs on merges to the main branch, automatically treating merged PRs as release candidates.</li><li><strong>Version Detection:</strong> Instead of relying on Maven snapshots or complicated versioning plugins, we extract the current version from the pom.xml, and simply increment it for the next release. This keeps things clean and deterministic.</li><li><strong>Docker Image Build + Push:</strong> We use Spring Boot’s built-in<a href="https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/htmlsingle/#build-image"> Cloud Native Buildpacks</a> via spring-boot:build-image to build OCI-compliant images. It’s fast, simple, and requires no Dockerfile.</li><li><strong>Kubernetes Deployment:</strong> The image tag placeholder ({{IMAGE_TAG}}) in the deployment manifest is replaced with the current version before being applied via kubectl.</li><li><strong>Committing Version Bump:</strong> After pushing the image, the workflow bumps the Maven version and commits it back to the main branch. This ensures the next PR starts from the correct version.<br>There is one caveat though — given that most likely your main branch will be protected by the Rulesets so you cannot simply push the change. There is an open feature request to allow GHA to bypass such rules, but in a meanwhile you would need to configure a <strong>Deploy key</strong> following <a href="https://github.com/orgs/community/discussions/25305#discussioncomment-10728028">this instruction</a>.</li></ul><p>Maven is a powerful tool, but it’s versioning model (especially around SNAPSHOTs) can often feel unnecessarily complex for small teams or microservices. In this pipeline, we’ve adopted a simpler, incremental versioning model where every release is just a number. No snapshots, no guesswork.</p><p>Lastly, here’s what the Kubernetes deployment.yaml file looks like:</p><pre>apiVersion: apps/v1<br>kind: Deployment<br>metadata:<br>  name: demo<br>spec:<br>  replicas: 1<br>  selector:<br>    matchLabels:<br>      app: demo<br>  template:<br>    spec:<br>      containers:<br>        - name: demo<br>          image: us-docker.pkg.dev/project/repo/demo:{{IMAGE_TAG}}<br>          imagePullPolicy: Always<br>          ports:<br>            - containerPort: 8080<br>          resources:<br>            requests:<br>              memory: &quot;1Gi&quot;<br>              cpu: &quot;2&quot;</pre><p>This manifest defines a minimal but complete Kubernetes deployment for your Spring Boot application. It launches a single pod using the newly built and tagged Docker image. The {{IMAGE_TAG}} placeholder is dynamically replaced by the GitHub Action at release time, ensuring the correct version is deployed.</p><h3>Afterwards</h3><p>With this quick setup, you already have a functioning CI/CD pipeline: a merged PR automatically builds your application, tags and pushes the image, bumps the version, and rolls out the update to your cluster. Simple, clean, and production-friendly</p><p>As a long-time Jenkins user (and even a plugin developer), I’ve recently started migrating all my repositories to GitHub Actions, and for good reason. The integration into modern DevOps workflows is seamless, the setup is fast, and the learning curve is refreshingly low.</p><p>What we’ve covered here is just the beginning. GitHub Actions offers a rich ecosystem that supports: Conditional workflows based on pull request labels, Multi-language build pipelines, Secure, keyless authentication into cloud providers, Integration with tools like SonarQube, Snyk, and more.</p><p>If you’re doing DevOps — or moving toward GitOps — GitHub Actions is absolutely worth your time. Don’t sleep on it.</p><p>All of the code can be found at <a href="https://github.com/Enigo/gha-ci">this GitHub repository</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c52a2a233cb9" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Java: Calling Go library over JNA]]></title>
            <link>https://pudding-entertainment.medium.com/java-calling-go-library-over-jna-48c7cf74cc88?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/48c7cf74cc88</guid>
            <category><![CDATA[jna]]></category>
            <category><![CDATA[java]]></category>
            <category><![CDATA[programming]]></category>
            <category><![CDATA[go]]></category>
            <category><![CDATA[jni]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 08 Sep 2024 15:58:52 GMT</pubDate>
            <atom:updated>2024-09-08T15:58:52.961Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/0*LxRUwtLjoqhK35F8" /><figcaption>Generated by ChatGPT</figcaption></figure><p>When working with Java, there are times when you need to make use of native libraries written in other languages, like C++, Rust or even Go. Traditionally, Java Native Interface (JNI) has been the go-to solution for these tasks. However, JNI can be complex and cumbersome, requiring significant boilerplate code and deep knowledge of both Java and the target language’s memory management.</p><p>Java Native Access (JNA) offers a more streamlined and user-friendly alternative. With JNA, you can directly call native functions from dynamic libraries without the need for extensive coding or manual handling of pointers and memory. This ease of use makes JNA particularly appealing for developers who need to integrate native code into their Java applications but want to avoid the steep learning curve and pitfalls associated with JNI.</p><p>In this article, we’ll explore the practical use of JNA by demonstrating how to call methods from a Go library within a Java application.</p><h4>Part 1. Building the Go Library</h4><p>In this section, we’ll walk through the process of creating a simple Go library that we’ll later use in our Java application. The Go library will contain a Contains function that checks whether a specific string exists within a slice of strings:</p><pre>package main<br><br>import &quot;C&quot;<br><br>//export Contains<br>func Contains(values []string, value string) bool {<br> for _, v := range values {<br>  if v == value {<br>   return true<br>  }<br> }<br> return false<br><br>}<br><br>func main() {<br>}</pre><p>The code might look simple and mostly self-explanatory, but there are a few key aspects to highlight.</p><ul><li>The import “C” statement is crucial when building Go libraries meant to be used with other languages. This import enables Go to interface with C, allowing us to create functions that can be called from C-compatible languages, like Java via JNA. Essentially, it bridges the gap between Go and the external world.</li><li>//export Contains is more than just documentation — it instructs the Go compiler to export the Contains function, making it accessible to other languages, like Java in our case. The //export directive must be placed immediately before the function definition you intend to export.</li></ul><p>This Go code is now ready to be compiled into a shared library that we can call from Java using JNA. In order to do that execute the following build command:</p><pre>go build -buildmode=c-shared -o bin/library.so main.go</pre><p>Under the hood it will use the <a href="https://pkg.go.dev/cmd/cgo">cgo build</a> command. You’ll end up with two important files: a shared object file (.so) and a C header file (.h). These files are essential for integrating the Go library with other programming languages like Java. For the sake of this article I’ve committed both of those files, but generally you shouldn’t do that.</p><p>The .so file is a shared object file that contains the compiled code of your Go library. It’s this file that Java, through JNA, will load and interact with. The shared object file is a dynamic library that can be loaded at runtime, making it possible for Java to call the Contains function defined in our Go code.</p><p>The .h file is a C header file generated by Go when you use cgo to build the library. This file serves as an interface between your Go code and other languages like C and, by extension, Java. You can check out the complete file <a href="https://github.com/Enigo/java-go-jna/blob/master/library/bin/library.h">over here</a>, in this article however we will only focus on the key parts of it:</p><pre>extern GoUint8 Contains(GoSlice values, GoString value);</pre><p>This line declares the Contains function in a way that can be recognized by other languages. The function expects a GoSlice and a GoString as arguments, making it possible to pass Go-like slices and strings from Java to this function via JNA. That’s the function that we will be calling from the Java side.</p><pre>typedef struct { const char *p; ptrdiff_t n; } _GoString_;</pre><p>This line defines a type _GoString_, which is used to represent strings in Go when interfacing with other languages. Go strings are not simple null-terminated C strings; they include both a pointer to the string data p and the string’s length n. This ensures that strings are handled correctly even if they contain null bytes.</p><pre>typedef struct { void *data; GoInt len; GoInt cap; } GoSlice;</pre><p>This line defines a type GoSlice, which is used to represent slices in Go. A Go slice includes a pointer to the underlying array data, its length len, and its capacity cap.</p><p>These structures are crucial because they ensure that data types like strings and slices, which are native to Go, can be correctly interpreted and used in other languages. Now let’s jump over to the Java side of this project.</p><h4>Part 2. Building the Java Application</h4><p>First we’ll create a Java interface called LibraryBridge that will serve as a bridge between our Java application and the Go library we previously created. This interface will define the structures and methods needed to interact with the Go code using JNA.</p><pre>import java.util.List;<br>import com.sun.jna.Library;<br>import com.sun.jna.Pointer;<br>import com.sun.jna.Structure;<br><br>public interface LibraryBridge extends Library {<br>    class GoSlice extends Structure {<br>        public static class ByValue extends GoSlice implements Structure.ByValue {<br>        }<br><br>        public Pointer data;<br>        public long len;<br>        public long cap;<br><br>        @Override<br>        protected List&lt;String&gt; getFieldOrder() {<br>            return List.of(&quot;data&quot;, &quot;len&quot;, &quot;cap&quot;);<br>        }<br>    }<br><br>    class GoString extends Structure {<br>        public static class ByValue extends GoString implements Structure.ByValue {<br>        }<br><br>        public String p;<br>        public long n;<br><br>        @Override<br>        protected List&lt;String&gt; getFieldOrder() {<br>            return List.of(&quot;p&quot;, &quot;n&quot;);<br>        }<br>    }<br><br>    boolean Contains(GoSlice.ByValue values,<br>                     GoString.ByValue value);<br>}</pre><p>The LibraryBridge interface extends the Library interface provided by JNA, which enables us to load and interact with the native shared library generated from our Go code. Let’s break down the code:</p><ul><li>The GoString class represents the Go string structure in Java. It matches the _GoString_ struct in the Go library, which includes a pointer to the string data and the length of the string.</li><li>The GoSlice class represents the Go slice structure in Java. This structure is defined to match the GoSlice type from the Go library, which includes a pointer to the data, the length of the slice, and its capacity.</li><li>The ByValue nested class inside ensures that instances will be passed by value to the native function, mimicking the behavior of Go slices and strings.</li><li>Both classes have the getFieldOrder method that returns a list of the field names in the order they are defined in the Go structure. JNA uses this method to correctly map the Java fields to their corresponding native fields.</li><li>Lastly, Contains method corresponds to the Contains function in our Go library. The method takes two parameters: a GoSlice.ByValue representing the slice of strings, and a GoString.ByValue representing the string to search for within the slice. The method returns a boolean indicating whether the string was found in the slice.</li></ul><p>Now that we’ve established the LibraryBridge interface to interact with the Go library, the next step is to create a class that will load this library into the Java application. This class will handle the process of extracting and loading the shared library (library.so) at runtime:</p><pre>import java.io.IOException;<br>import com.sun.jna.Native;<br><br>public class LibraryLoader {<br>    public static LibraryBridge INSTANCE;<br><br>    static {<br>        loadLib();<br>    }<br><br>    private static void loadLib() {<br>        try {<br>            final var file = Native.extractFromResourcePath(&quot;library.so&quot;, LibraryLoader.class.getClassLoader());<br>            INSTANCE = Native.load(file.getAbsolutePath(), LibraryBridge.class);<br>        } catch (final IOException e) {<br>            throw new RuntimeException(e);<br>        }<br>    }<br>}</pre><p>In this LibraryLoader class, the Go library is loaded statically using a static initializer block. By doing so, we ensure that the Go shared library is loaded as soon as the LibraryLoader class is accessed, typically at the start of the application. This guarantees that the native methods provided by the library are available and ready to be invoked throughout the application’s lifecycle.</p><p>In the loadLib() method, we use the Native.extractFromResourcePath() function to load the library.so file. This method works seamlessly with the Maven resources folder structure, which is a common setup in Java projects. With Maven one typically stores resource files, such as native libraries, in the src/main/resources directory. When you build your project, Maven includes these resources in the classpath, making them accessible at runtime. The Native.extractFromResourcePath(“library.so”, LibraryLoader.class.getClassLoader()) function extracts the library.so file from the application’s resources and places it in a temporary directory where it can be accessed by the operating system. This is particularly useful because it allows the native library to be packaged within the JAR file, and JNA handles the extraction process automatically. This approach ensures that your native library is always accessible, regardless of the environment in which your application is deployed. Whether running locally during development or as a packaged application in production, Native.extractFromResourcePath simplifies the process of managing native libraries, making them easy to distribute and use.</p><p>Finally, we’ll implement the actual method that interacts with our Go library through the LibraryBridge interface. This method will leverage JNA to convert Java data types into Go-compatible types, call the Go Contains function, and return the result. Below is the implementation, followed by a detailed explanation.</p><pre>import com.sun.jna.Memory;<br><br>public class Main {<br><br>    public boolean containsGo(final List&lt;String&gt; valuesToCheck, final String valueToCheck) {<br>        final var values = convertValues(valuesToCheck);<br>        final var size = values[0].size();<br>        try (final var memory = new Memory((long) values.length * size)) {<br>            var offset = 0L;<br>            for (final var value : values) {<br>                value.write();<br>                memory.write(offset, value.getPointer().getByteArray(0, size), 0, size);<br>                offset += size;<br>            }<br><br>            final var valuesAsSlice = new LibraryBridge.GoSlice.ByValue();<br>            valuesAsSlice.data = memory;<br>            valuesAsSlice.len = values.length;<br>            valuesAsSlice.cap = values.length;<br><br>            return LibraryLoader.INSTANCE.Contains(valuesAsSlice, getGoString(valueToCheck));<br>        }<br>    }<br><br>    private static LibraryBridge.GoString[] convertValues(final List&lt;String&gt; valuesToCheck) {<br>        final var values = (LibraryBridge.GoString[]) new LibraryBridge.GoString().toArray(valuesToCheck.size());<br>        var i = 0;<br>        for (final var pair : valuesToCheck) {<br>            values[i].p = pair;<br>            values[i++].n = pair.length();<br>        }<br>        return values;<br>    }<br><br>    private static LibraryBridge.GoString.ByValue getGoString(final String value) {<br>        final var valueAsGoString = new LibraryBridge.GoString.ByValue();<br>        valueAsGoString.p = value;<br>        valueAsGoString.n = valueAsGoString.p.length();<br>        return valueAsGoString;<br>    }<br>}</pre><p>The first step is to convert the list of Java strings (valuesToCheck) into an array of GoString structures, which can be passed to the Go function. This is achieved through the convertValues method. This method creates an array of LibraryBridge.GoString structures, where each GoString contains a pointer to a Java string p and its length n. This array mirrors the Go slice structure, making it compatible with our Go library.</p><p>After this we need to build a “slice” with the values to send to Go. The Memory object is used to allocate a block of memory that can store all the strings in the Go slice. This memory block is needed because Go expects a continuous block of memory for its slices, and JNA facilitates this process.</p><p>Each string in the array is written into the allocated memory block. The offset is updated after each string to ensure that each string is stored sequentially in memory. After writing the strings to memory, we create an instance of LibraryBridge.GoSlice.ByValue. We set the data field to point to the memory block, and set both len and cap fields to the number of elements in the slice. This structure mirrors the Go slice in a way that the Go library can understand.</p><p>With all of that we are now ready to call the Contains function using the static reference to the LibraryBridge instance.</p><h4>Part 3. Testing</h4><p>There are various ways to test this code, in this article we’ve decided to utilise the power of JUnit tests:</p><pre>import java.util.List;<br>import org.junit.jupiter.api.Assertions;<br>import org.junit.jupiter.api.Test;<br><br>public class MainTest {<br><br>    @Test<br>    void testContains() {<br>        final var valuesToCheck = List.of(&quot;value1&quot;, &quot;value2&quot;, &quot;value3&quot;);<br>        final var valueToCheck = &quot;value2&quot;;<br><br>        final var main = new Main();<br>        final var contains = main.containsGo(valuesToCheck, valueToCheck);<br><br>        Assertions.assertTrue(contains);<br>    }<br><br>    @Test<br>    void testDoesNotContain() {<br>        final var valuesToCheck = List.of(&quot;value1&quot;, &quot;value2&quot;, &quot;value3&quot;);<br>        final var valueToCheck = &quot;missing&quot;;<br><br>        final var main = new Main();<br>        final var contains = main.containsGo(valuesToCheck, valueToCheck);<br><br>        Assertions.assertFalse(contains);<br>    }<br>}</pre><p>With this simple test suite we can assure that the code works correctly and that the Go library is returning expected results.</p><h4>Afterwards</h4><p>Integrating Java with native libraries, especially those written in Go, can seem daunting at first. However, as we’ve demonstrated, JNA significantly simplifies this process. With JNA, you can bypass much of the complexity typically associated with JNI, allowing you to focus more on functionality and less on boilerplate code. The relative ease of use that JNA offers makes it an appealing choice for developers looking to extend Java applications with the performance and capabilities of native code.</p><p>That said, while JNA is powerful, it’s not without its challenges. Real-world interactions between Java and native libraries often involve dealing with complex data structures, memory management, and the intricacies of different language ecosystems. JNA can abstract away much of this complexity, but it’s important to remember that it doesn’t eliminate it entirely. Issues like performance overhead, debugging difficulties, and the potential for subtle bugs when dealing with pointers and memory are still very much present. Therefore, JNA should be used wisely and where it is appropriate</p><p>All of the code can be found at <a href="https://github.com/Enigo/java-go-jna/tree/master">this GitHub repository</a>.</p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=48c7cf74cc88" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Jenkins: How to create a plugin]]></title>
            <link>https://pudding-entertainment.medium.com/jenkins-how-to-create-a-plugin-c998f9ab07eb?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/c998f9ab07eb</guid>
            <category><![CDATA[ci-cd-pipeline]]></category>
            <category><![CDATA[jenkins-pipeline]]></category>
            <category><![CDATA[jenkins]]></category>
            <category><![CDATA[development]]></category>
            <category><![CDATA[plugins]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Wed, 20 Mar 2024 04:16:43 GMT</pubDate>
            <atom:updated>2024-03-20T04:16:43.936Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*yMU4AqScJ036L-2LMK_e7Q.jpeg" /><figcaption>Generated by Bing Image Creator</figcaption></figure><p>Ah, Jenkins. The venerable old guardian of the CI/CD realm, a tool that has seen the rise and fall of many a software trend, yet stands steadfast — much like that one quirky relative we all have who’s stuck in their ways but somehow indispensable at family gatherings. Jenkins, with its infinite configurability and the charming allure of its plugins is both adored and admonished. It’s the seasoned performer that can pull off any trick in the book, albeit with a rehearsal or two. Jenkins doesn’t just survive in the ever-evolving CI/CD landscape; it thrives, continuing to be a <a href="https://www.datanyze.com/market-share/ci--319/jenkins-market-share">dominant player</a> that commands both respect and a healthy dose of developer memes.</p><p>Indeed, the diverse opinions on Jenkins reflect its complexity and versatility in the CI/CD landscape. Amid this backdrop, I’ve contributed into the ecosystem by releasing a Jenkins plugin named <a href="https://github.com/jenkinsci/pipeline-gcp-plugin">pipeline-gcp</a>. In this article I’ll share a high-level overview of the process.</p><p>While Jenkins provides comprehensive documentation on plugin creation, navigating through it can be somewhat challenging, as the information is dispersed across various pages. Moreover, sharing insights from a real developer’s experience can be invaluable, especially for first-time contributors looking to make their mark.</p><p>Conceptually, there are 3 stages that you will have to go through while creating a plugin: Development, Hosting request and, if accepted, Release.</p><h3><strong>Development</strong></h3><p>This phase is perhaps the most extensive yet straightforward part of the plugin creation process. The journey typically begins by setting up an empty project. Jenkins makes this step user-friendly by offering a convenient archetype, which you can initialize with the following Maven command:</p><pre>mvn -U archetype:generate -Dfilter=io.jenkins.archetypes:</pre><p>With your project created, the next step is to start actual development. In my experience, the most effective way to figure out what to build — specifically, what Java code to write — is to draw inspiration from existing plugins or to leverage a modern AI tool for guidance. For example, for my plugin I looked to <a href="https://github.com/jenkinsci/pipeline-aws-plugin">pipeline-aws</a>, a similar plugin designed for AWS. This approach helped me craft the first functional prototype of my plugin.</p><p>Next let’s turn our attention to one of the more deceptively challenging aspects of programming: naming. While the archetype efficiently lays the groundwork for your project, it’s essential to adhere to the established naming conventions when choosing the plugin name. Specifically, the artifactId should not include the word plugin, although it’s customary for the repository name to contain it. But if the plugin is accepted into jenkinsci, the repository name will incorporate plugin by default as you can see it in my plugin name at <a href="https://github.com/jenkinsci/pipeline-gcp-plugin">https://github.com/jenkinsci/pipeline-gcp-plugin</a>.</p><p>An often overlooked yet critical factor in a plugin’s success is its documentation. For your plugin to be embraced and utilized by the Jenkins community, developers must have clear instructions on its usage. The most effective way to achieve this is by creating a comprehensive and easy-to-understand README file. The importance of this document cannot be overstated: it not only serves as the first point of contact for potential users but also lays the foundation for your plugin’s representation on <a href="https://plugins.jenkins.io/">https://plugins.jenkins.io</a> once released. Therefore, ensure that the README has all the details on how to use the plugin, highlighting its features, setup instructions, and any prerequisites. Remember, a well-documented plugin significantly lowers the entry barrier for new users and increases its potential impact within the community.</p><p>As the plugin development progresses, you might ponder on enhancing the user interface, particularly how to incorporate text descriptions or functionalities within the Plugin Manager or Pipeline Syntax views. Jenkins employs <a href="https://www.jenkins.io/doc/developer/forms/jelly-form-controls">Jelly scripting</a> for UI customization in plugins. To add a plugin description visible in the Plugin Manager, you’ll need to include an index.jelly file in the resources directory. Here’s an example structure:</p><pre>&lt;?jelly escape-by-default=&#39;true&#39;?&gt;<br>&lt;div&gt;<br>   This plugin adds Jenkins pipeline steps to interact with the GCP API.<br>&lt;/div&gt;</pre><p>After that the description will be displayed as:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/470/0*w-LSRX_UPz5q4Lgj" /></figure><p>You would also use the same approach to define the pipeline syntax for your plugin functions, particularly for steps that accept parameters. To achieve that you should add config.jelly and help.html files to the resources folder, aligned with the Java class path of the given function. Here’s how you could structure these files:</p><pre>&lt;?jelly escape-by-default=&#39;true&#39;?&gt;<br>&lt;j:jelly xmlns:j=&quot;jelly:core&quot; xmlns:st=&quot;jelly:stapler&quot; xmlns:f=&quot;/lib/form&quot;&gt;<br>   &lt;f:entry field=&quot;credentialsId&quot; title=&quot;Credentials Id&quot;&gt;<br>       &lt;f:textbox/&gt;<br>   &lt;/f:entry&gt;<br>&lt;/j:jelly&gt;</pre><pre>&lt;div&gt;<br>   The id of credentials, presumably stored as Secret file<br>&lt;/div&gt;</pre><p>Now if you navigate to the Pipeline Syntax view (<em>Jenkins/job/&lt;anyjob&gt;/pipeline-syntax</em>), you should see a proper description and a generated block:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/468/0*1_vUxHon15bQVK3-" /></figure><p>To wrap up the development phase and before submitting the hosting request, it’s crucial to ensure that your plugin meets all necessary quality checks. Execute the following Maven command and make sure it passes:</p><pre>mvn clean verify package</pre><p>Jenkins projects typically employ the <a href="https://github.com/diffplug/spotless">Spotless plugin</a> to enforce consistent code formatting. Successfully passing this command indicates your code adheres to the required standards and is free from obvious issues.</p><p>With all the steps covered, and of course with the actual code implemented and tested, you are now ready to submit a hosting request.</p><h3><strong>Hosting request</strong></h3><p>To kick-start the hosting review for your plugin, head over to the <a href="https://github.com/jenkins-infra/repository-permissions-updater">repository-permissions-updater</a> and create a new issue, detailing all necessary information. If you’re looking for a point of reference, feel free to draw inspiration from <a href="https://github.com/jenkins-infra/repository-permissions-updater/issues/3806">my submission</a>.</p><p>The process involves clearing two automated checks — security and hosting — before moving to the manual review by a member of the hosting team. In my experience, this phase was actually quite swift; after addressing the review comments, my hosting request received approval within just two days. Following approval, you’ll be welcomed into the Jenkins organization via email, and your plugin repository will be officially forked into jenkinsci.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/769/0*ZJ7Fi8n2xpGdLuGT" /></figure><p>Additionally, you’ll be granted owner rights to your repository. This is a perfect time to fine-tune its settings, including merge strategies, pull requests, branch protections, and more, to suit your preferences.</p><p>A corresponding permissions file is typically auto-generated in the repository-permissions-updater, although in my case the pipeline was broken (fixed in <a href="https://github.com/jenkins-infra/repository-permissions-updater/pull/3816">this PR</a>) so I had to create it <a href="https://github.com/jenkins-infra/repository-permissions-updater/pull/3815/files">manually</a>. This step marks your exciting entry into the vibrant Jenkins community!</p><p>However, the journey doesn’t end here. The next step is to release your plugin into the wild, making it available for users worldwide.</p><h3><strong>Release</strong></h3><p>Gone are the days when plugin releases were manually executed from local development machines. In a fortunate turn of events, this process has been modernized and is now largely automated through GitHub Actions, with just a few adjustments to the project’s configuration needed:</p><ul><li>Submit a modification to the <a href="https://github.com/jenkins-infra/repository-permissions-updater">repository-permissions-updater</a> to enable CD for your plugin.</li><li>Remove release-drafter.yml files</li><li>Add a cd.yml workflow file</li><li>Insert -Dchangelist.format=%d.v%s into the maven.config file</li><li>Remove revision from pom.xml and change changelist to 999999-SNAPSHOT</li></ul><p>These adjustments were implemented in my project through a couple of PRs, detailed <a href="https://github.com/jenkinsci/pipeline-gcp-plugin/pull/3/files">here</a> and <a href="https://github.com/jenkins-infra/repository-permissions-updater/pull/3817">here</a>. For those seeking a more comprehensive explanation, the <a href="https://www.jenkins.io/doc/developer/publishing/releasing-cd/">official Jenkins documentation</a> is an invaluable resource.</p><p>Following these modifications with the right labels in place, merging a PR will trigger the release of a new plugin version.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/686/0*ZFJoFx77D1KrrkxE" /></figure><p>Keep an eye on <a href="https://repo.jenkins-ci.org/ui/packages/gav:%2F%2Fio.jenkins.plugins:pipeline-gcp?name=pipeline-gcp&amp;type=packages">Artifactory</a> to see your plugin listed, and don’t forget to verify its presence on the <a href="https://plugins.jenkins.io/pipeline-gcp/">Jenkins plugins page</a>. It should only take a few hours to reflect the updates, but in my case there was yet <a href="https://github.com/jenkins-infra/helpdesk/issues/3999">another bug</a> so the waiting time was quite long. It is resolved now, so hopefully you won’t experience anything like that.</p><h3><strong>Afterwards</strong></h3><p>Congratulations on successfully releasing your plugin into the Jenkins ecosystem! As you join the ranks of developers enhancing this powerful tool, remember that your efforts help in shaping the future of CI/CD processes. Here’s to many more contributions and continued collaboration within this dynamic community!</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*S16knU3B1lpjALviZlWJ9Q.jpeg" /><figcaption>Generated by Bing Image Creator</figcaption></figure><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=c998f9ab07eb" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Rust: Adding Search functionality to website using Yew]]></title>
            <link>https://pudding-entertainment.medium.com/rust-adding-search-functionality-to-website-using-yew-e7673523026a?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/e7673523026a</guid>
            <category><![CDATA[sqlx]]></category>
            <category><![CDATA[search]]></category>
            <category><![CDATA[web-development]]></category>
            <category><![CDATA[yew]]></category>
            <category><![CDATA[rust]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sun, 14 Jan 2024 04:58:10 GMT</pubDate>
            <atom:updated>2024-01-14T04:58:10.335Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*d3depxOyG6Uno3XhxG2uPg.jpeg" /><figcaption>Generated by Bing Image Creator</figcaption></figure><p>In the ever-evolving landscape of web development, choosing the right tools can make all the difference. Rust, renowned for its performance, safety, and concurrency, emerges as a powerhouse in this domain. Its unique blend of low-level control and modern language features has made it a favourite among developers seeking efficiency without compromising on safety.</p><p>In this tutorial, with the help of Rust, Yew, Actix Web, and Sqlx we’ll add an essential piece any website will need at some point — search functionality.</p><h4>Prerequisites</h4><p>I’m using Rust version 1.75 and Yew version 0.21. This tutorial assumes prior knowledge of Rust and Yew and focuses more on practical usage of the language and the framework.</p><p>I’ll be using the project I’m actively working on at the moment — <a href="https://illuvi-analytics.com/">illuvi-analytics.com</a>. The relevant parts of it will be stripped into self-sufficient code snippets, and you are welcome to check the entire project out.</p><p>If you are not yet familiar with Yew, take a look at one of my previous articles — <a href="https://medium.com/@pudding-entertainment/rust-building-web-frontend-with-yew-ca7421fe01d4">Rust: Building Web Frontend with Yew</a></p><p>There are 3 parts in this tutorial: Problem statement, Backend and Frontend</p><h4>Background</h4><p>If you’re intrigued by the project and the broader scope of web3 technologies, I recommend exploring the Background section in one of my previous articles — <a href="https://pudding-entertainment.medium.com/rust-how-to-create-rest-api-service-586479516da2">Rust: How to consume REST API</a>. This will give you a solid foundation in the concepts we’re building upon.</p><p>While I employ a domain-specific data model in this tutorial, the concepts and techniques discussed are universal. Therefore, you can seamlessly adapt and apply this approach to your projects, regardless of your specific interest or expertise in the finer details of the domain in question.</p><h4>Part 1. Problem statement</h4><p>At first glance, the task seems simple: add a search box to the website, ideally in the header, allowing users to input their queries. However, the simplicity is deceptive, as deeper exploration reveals numerous edge cases that require careful consideration.</p><p><strong>Frontend considerations<br></strong>There are several critical design decisions to make that will define the overall user experience of the search functionality, making it user-friendly and visually appealing.</p><p>First, consider the interaction style of the search box. Will it trigger a search automatically as the user types, or will it require pressing “Enter”? This decision impacts the immediacy of the user’s search experience.</p><p>Next, decide how to present the search results. Options include displaying them in a popup window, on a dedicated results page, or a combination of both. This choice significantly influences the user’s navigation flow and ease of accessing the information.</p><p>Lastly, determining the number of results to fetch and display is crucial. This involves balancing the need to provide comprehensive information with maintaining a clean and uncluttered interface.</p><p>In my specific use case, I opted for a dynamic approach. The search box activates as the user begins typing, displaying results in a pop-up. This method offers immediate feedback and a seamless user experience, keeping the user engaged without navigating away from their current page.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/279/0*TS8qQQigDIVwpVvu" /></figure><p><strong>Backend considerations<br></strong>The backend receives an arbitrary search string, which must be safely used for querying multiple database fields. This scenario raises immediate concerns about SQL injection attacks and the need for robust database protection. My experience with SQLX in Rust has shown its effectiveness in guarding against such vulnerabilities, primarily through its support for prepared statements. For more details, refer to the <a href="https://docs.rs/sqlx-core/0.7.3/src/sqlx_core/query_builder.rs.html#99">SQLX documentation</a>.</p><p>Speed and efficiency are paramount in search functionality. Initially, optimizing the search query is crucial. In databases like Postgres and MySQL, utilizing EXPLAIN ANALYZE with your query helps identify execution bottlenecks and areas for improvement. Strategies might include adding indexes to search fields, separating certain fields into distinct columns (particularly with JSON data), or creating views or materialized views. Depending on your specific requirements, you might also consider parallelizing queries, introducing a caching layer or even reevaluating your choice of database technology to better suit your needs.</p><p>Having outlined the key considerations for both the backend and frontend, we now have a solid foundation to begin the development phase. Let’s dive into the development process, starting with the backend part.</p><h4>Part 2. Backend</h4><p>We begin by defining a controller for our search functionality using <a href="https://github.com/actix/actix-web">actix-web</a>. As we discussed previously, we will have a single search value sent from the frontend, which will be defined in a struct:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c73c06880c238ff750040ac860897fc2/href">https://medium.com/media/c73c06880c238ff750040ac860897fc2/href</a></iframe><p>An important aspect of our backend is the use of a database pool, rather than opening and closing connections for each request. You can see it being passed as a parameter in the get_search_results method. This approach, implemented in the main method, significantly enhances query performance and reduces database overhead:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/bfaa55764cd1d9dbda62a059a6c8df38/href">https://medium.com/media/bfaa55764cd1d9dbda62a059a6c8df38/href</a></iframe><p>The create_pool function configures the database connection with essential parameters like host, port, and credentials. You might tweak many other parameters here as well:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/95791d49b5d5abf2de29319749d834b7/href">https://medium.com/media/95791d49b5d5abf2de29319749d834b7/href</a></iframe><p>You might recognize that env_utils from my previous tutorials:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/f00c75217dcf9d13cf182ca938ab8ca1/href">https://medium.com/media/f00c75217dcf9d13cf182ca938ab8ca1/href</a></iframe><p>Next, let’s create a database querying service:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c2670e49321dcddd403a6a712fd7e454/href">https://medium.com/media/c2670e49321dcddd403a6a712fd7e454/href</a></iframe><p>In my case here the search works on two fields — integer token_id and json metadata with a field called name in it.</p><p>A noteworthy aspect of our query implementation is the concatenation technique used in wildcard searches (‘%’ || $1 || ‘%’). This approach is required due to the way parameter binding works.</p><p>A small but significant optimization is applied to the token_id field. Recognizing that token_id is a numeric field, we first verify the search input as a number before initiating a more resource-intensive wildcard search.</p><p>There were a few other improvements considered, but eventually discarded:</p><ul><li>Creating an index on the metadata.name field did not result in notable performance improvements and was therefore set aside.</li><li>Extracting metadata.name into its own column and using a database trigger for updates. While this approach did reduce the response time for negative searches from around 3 seconds to under 2 seconds, it was not implemented due to its limited necessity for my website.</li><li>Implementing a caching read-through layer was also explored. However, given its complexity it was an excessive measure at this point. It remains an area of interest for future enhancements though.</li></ul><p>Finally, the data models SearchData and AssetContentData are defined. These models structure the data sent from the backend to the frontend, ensuring a consistent and predictable format for the search results. The file itself will be defined in a shared module used by both frontend and backend:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/b262b5e5bca35507b09090f464eaa8f2/href">https://medium.com/media/b262b5e5bca35507b09090f464eaa8f2/href</a></iframe><p>With that the backend part is now complete and we go ahead and implement the search box on the frontend.</p><h4>Part 3. Frontend</h4><p>In this section we’re combining the capabilities of Yew and Bootstrap 5 to build the search interface. While Yew is a robust framework, it’s important to note that unfortunately it often requires verbose code, particularly in complex scenarios like this one where there are multiple states to manage.</p><p>The frontend implementation can be logically divided into three main tasks:</p><ol><li>Handling user input in the search box<br>Our first challenge is to efficiently handle user input in the search box. Unlike the straightforward approach in JavaScript, Yew requires a more nuanced method. To prevent sending each keystroke to the backend immediately, we’ll utilize the <a href="https://crates.io/crates/gloo-timers">gloo-timers</a> crate for timeout functionality.</li><li>Querying the backend and receiving results<br>After capturing the user’s input, it’s sent to the backend. This process involves using the <a href="https://crates.io/crates/reqwest">reqwest</a> crate to manage the data transfer.</li><li>Displaying the search results<br>The final step involves presenting the results to the user. For an optimal user experience, we’ll display the results in a dropdown view. During the data retrieval process, a spinner will indicate loading to the user, maintaining engagement. While the creation of the spinner is outside the scope of this tutorial, its presence is crucial for a seamless user experience.</li></ol><p>The end result Search function component will look like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/4ab89f624ddbbb64d967effd8a1022a8/href">https://medium.com/media/4ab89f624ddbbb64d967effd8a1022a8/href</a></iframe><p>As you can see in the code above there are 4 states defined:</p><ul><li>search: Captures the user’s input.</li><li>search_data: Holds data retrieved from the backend.</li><li>typed_state: Indicates whether the user has entered any input.</li><li>timeout_state: Manages the timing to send a backend request, ensuring it’s triggered only after the user stops typing.</li></ul><p>To enhance user experience, five distinct actions (callbacks) were implemented:</p><ul><li>oninput: Manages user input. This action uses the gloo-timers crate’s Timeout struct to delay updating the search state which is used as a dependency in use_effect_with dependency therefore delaying the backend request</li><li>onsubmit: Prevents page reload on form submission (Enter key press) within an HTML form context.</li><li>onfocusout: Automatically closes the popup when the user clicks outside the search.</li><li>onfocusin: Reopens the popup when the user clicks back into the search box.</li><li>onclick: Resets the state upon clicking a search result, typically leading to a redirect to the relevant page.</li></ul><p>The verbosity in our Yew implementation arises primarily from the need for each callback to clone the state, resulting in numerous cloned objects. The rest of the code follows standard Yew practices, enhanced with Bootstrap 5 classes for aesthetic appeal.</p><p>For those interested in exploring the complete project, the code is available on <a href="https://github.com/Enigo/illuvi-analytics">GitHub</a>. While some parts are specific to my project, the general principles and techniques are applicable across various Yew applications.</p><p>The final result will look like this:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/435/1*lizPFwMXuraFcf227yzamQ.gif" /></figure><h4>Afterwards</h4><p>Yew, despite being a newer player in the web framework arena, is fully capable of powering dynamic and functional websites with Rust’s robustness. This journey through creating a search functionality showcases Yew’s potential in real-world applications. It demonstrates that this framework can be used to build complex features with the efficiency and safety that Rust offers. I believe that with time it will mature and become the next big thing in web development!</p><p>Do check out my Yew-powered website <a href="https://illuvi-analytics.com/">https://illuvi-analytics.com</a></p><h4>Support</h4><p>If you like the content you read and want to support the author — thank you very much!<br>Here is my Ethereum wallet for tips:<br><strong>0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e7673523026a" width="1" height="1" alt="">]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[AWS: How to deploy ScyllaDB cluster]]></title>
            <link>https://pudding-entertainment.medium.com/aws-how-to-deploy-scylladb-cluster-6778f2c4ad47?source=rss-357fbf3e95e1------2</link>
            <guid isPermaLink="false">https://medium.com/p/6778f2c4ad47</guid>
            <category><![CDATA[ansible]]></category>
            <category><![CDATA[aws]]></category>
            <category><![CDATA[dynamodb]]></category>
            <category><![CDATA[jenkins]]></category>
            <category><![CDATA[scylladb]]></category>
            <dc:creator><![CDATA[Pudding Entertainment]]></dc:creator>
            <pubDate>Sat, 16 Sep 2023 13:12:27 GMT</pubDate>
            <atom:updated>2023-09-16T13:14:57.490Z</atom:updated>
            <cc:license>https://creativecommons.org/licenses/by-nd/4.0/</cc:license>
            <content:encoded><![CDATA[<figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*lxe4Pbs7F-SmcP-W8-fs2g.jpeg" /><figcaption>Generated by Bing Image Creator</figcaption></figure><p>Amazon Web Services has long been the go-to destination for startups and enterprises alike looking to launch new projects swiftly and efficiently. The allure lies in its extensive array of managed services, designed to streamline the development process and allow businesses to focus on their core competencies. However, as time passes and your project matures, the financial realities of operating within the AWS ecosystem may start to emerge. The convenience of AWS-managed services comes at a cost, and what begins as an economical and expedient choice can gradually evolve into a budgetary challenge. Scaling resources vertically and horizontally, while maintaining the performance and reliability expected by your users, can become a substantial financial burden.</p><p>For some, DynamoDB — the NoSQL database service provided by AWS — has been the backbone of their data storage needs. Coupled with DynamoDB Accelerator (DAX), it seemed like the perfect solution for fast, scalable, and fully managed databases. However, the reality of operating DynamoDB and DAX at scale has shown that they may not always live up to their promises of reliability and cost-effectiveness.</p><p>In this article, we embark on a journey to explore an alternative: ScyllaDB, open source edition. From cost considerations to reliability concerns, we’ll uncover the compelling arguments that drive this shift in database strategy, and how it might benefit your long-term operations. Apart from this, I’ll also show you how to deploy your very own ScyllaDB cluster in AWS.</p><h3><strong>Part 1. Why switching?</strong></h3><p>In the realm of software engineering, the saying “if it works, don’t touch it” is often mentioned. It’s a principle that makes perfect sense — why tamper with a well-functioning system? However, in the dynamic landscape of modern technology, staying agile and cost-effective is essential. This often necessitates considering alternatives, even when you have a functional solution in place.</p><p>For those of us who’ve embarked on this journey of optimization, AWS offers a valuable tool: the <a href="https://calculator.aws/#/addService/DynamoDB">AWS Pricing Calculator</a>. It serves as our compass when navigating the complex terrain of cloud service costs. Let’s illustrate this with a quick cost calculation example.</p><p>In our DynamoDB scenario, we’re dealing with a workload that ranges from 1000 to 5000 write/read operations per second, supported by three DAX nodes of r5.large size.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/398/0*pTzSD9AeG4JTa2YW" /><figcaption>DynamoDB+DAX</figcaption></figure><p>Now, let’s compare this with a self-hosted solution where you’d manage three EC2 i3.large instances, each equipped with ScyllaDB.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/451/0*e7pyhnxRwGiXxumN" /><figcaption>EC2</figcaption></figure><p>The difference is nothing short of staggering.</p><p>Of course, the above is a simplified example, reflecting minimal instance types and counts. With ScyllaDB, the cost curve tends to be linear; scaling often means a straightforward doubling of expenses. DynamoDB and DAX, however, present a more intricate cost structure — try experimenting with it in the calculator.<br>It’s worth noting that while ScyllaDB does require investments in setup and potentially ongoing maintenance, the same holds true for DynamoDB and DAX however. In my personal experience, spanning the past two years, there have been seven (!) production incidents of different scale involving the AWS-managed solution. While individual experiences may vary, based on my own journey, I’ve come to strongly recommend exploring alternatives to DynamoDB and DAX.</p><p>Now that we’ve addressed the “why,” let’s delve into the technical aspects of the deployment process.</p><h3><strong>Part 2. Building AMI</strong></h3><p>Before we can launch our cluster, the first step will be the creation of an Amazon Machine Image (aka AMI) to run these EC2 instances. ScyllaDB provides prebuilt AMIs, ready to launch directly from the AWS console. However, you might find it necessary to install additional packages on these AMIs, such as Datadog or SSM agents. To accomplish this, we’ll begin by creating an Ansible playbook that installs ScyllaDB and sets up the required configuration files.<br>Notably, ScyllaDB is versatile when it comes to <a href="https://opensource.docs.scylladb.com/stable/getting-started/os-support.html#:~:text=The%20recommended%20OS%20for%20ScyllaDB%20Open%20Source%20is%20Ubuntu%2022.04.&amp;text=All%20releases%20are%20available%20as,is%20based%20on%20Ubuntu%2022.04.">supported operating systems</a>, with the recommended one being Ubuntu 22.04 at the time of writing. But bear in mind that the landscape can change, so always refer to the latest ScyllaDB documentation for the most up-to-date information.</p><p>Here’s the Ansible playbook, main.yaml, which accomplishes this task:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/59580298887223718f13e7b55281426c/href">https://medium.com/media/59580298887223718f13e7b55281426c/href</a></iframe><p>The playbook itself is rather straightforward. Let’s focus on the essential configuration file, scylla.yaml, and the pivotal scripts needed to bootstrap the database.</p><p>The scylla.yaml file serves as the central hub for tailoring ScyllaDB’s behavior. Below is a portion of the configuration file with key settings:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/0a07ce4beadab9014e0831921be43d5b/href">https://medium.com/media/0a07ce4beadab9014e0831921be43d5b/href</a></iframe><p>Depending on your use case you might need to set up more (or less) configurations. Make sure to configure Ec2Snitch since we are running in the AWS environment!</p><p>We will have a couple of bash scripts in order to facilitate the bootstrap process. These scripts ensure that ScyllaDB is properly configured and initialized.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e2fc359001b83186061da6b2b1103fc7/href">https://medium.com/media/e2fc359001b83186061da6b2b1103fc7/href</a></iframe><p>A cluster requires at least one seed node. Keep in mind that the scylla_setup script configuration is something you might need to adjust as per your specific use case.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/e95fa207f7167c38f5fcfc7265c4b274/href">https://medium.com/media/e95fa207f7167c38f5fcfc7265c4b274/href</a></iframe><p>Every other node will be bootstrapped with a reference to the seed node. Make sure that scylla_setup configurations are identical!</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/909b0d4a520d58db52f6f3ab97c3195e/href">https://medium.com/media/909b0d4a520d58db52f6f3ab97c3195e/href</a></iframe><p>The database schema should be set up on the seed node only. All other nodes will “receive” it while joining the cluster.</p><p>Finally setup.cql is the CQL script to set up the initial state of DB. It would usually contain the creation of a keyspace and tables in it. Sample file is below:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/0589f0e0c4d19f83d6fdbe49f1cf1142/href">https://medium.com/media/0589f0e0c4d19f83d6fdbe49f1cf1142/href</a></iframe><p>As you can see we replace &lt;DATACENTER&gt; here with the region <em>(but without a “-1” part)</em>. You can read more about this <a href="https://opensource.docs.scylladb.com/stable/operating-scylla/procedures/cluster-management/ec2-dc.html#ec2snitch-s-default-dc-and-rack-names-and-how-to-override-dc-names">here</a>.</p><p>Even though Bash scripts might seem old-school in today’s tech landscape, we use them here for a straightforward reason: they make things simple and reliable. These scripts automate the essential configurations, ensuring a reliable ScyllaDB deployment. However, if the prospect of dealing with Bash scripts feels antiquated or you prefer a more hands-off approach, there’s an alternative: ScyllaDB offers a managed service that handles these tasks for you, albeit for a fee.</p><p>Now that we have our AMI ready, it’s time to proceed with deploying it in the AWS environment, which we’ll explore in the upcoming section.</p><h3><strong>Part 3. Deploying cluster</strong></h3><p>Without further ado, let’s jump straight into the CloudFormation template.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/8bb791cd967d622569098b5eca9b5347/href">https://medium.com/media/8bb791cd967d622569098b5eca9b5347/href</a></iframe><p>Let’s get an overview of the key sections in the CloudFormation template:</p><ul><li>ScyllaInterNodeSecurityGroup: This is a security group for enabling communication between ScyllaDB nodes. It is crucial to have the <a href="https://opensource.docs.scylladb.com/stable/operating-scylla/admin.html#networking">required ports</a> opened in the security group, otherwise the nodes won’t be able to communicate.</li><li>ScyllaDBSeedInstance: An EC2 instance serving as the seed node for the ScyllaDB cluster. It runs the setup-seed-node.sh script during initialization.</li><li>ScyllaDBAutoscalingGroup: An Auto Scaling group that manages the ScyllaDB not seed nodes. It runs the setup-not-seed-node.sh script during initialization.</li></ul><p>Make sure to have the DependsOn attribute correctly configured — AutoScalingGroup relies on the seed node to be up and running with ScyllaDB bootstrapped.</p><p>The CloudFormation template allows customization by providing parameters like VPC ID, subnet IDs, AMI ID, and instance type. You can configure as many regions as you need like this:</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/c441d2db8ad3fcadc93ec42e5bf39fe2/href">https://medium.com/media/c441d2db8ad3fcadc93ec42e5bf39fe2/href</a></iframe><p>As you might have noticed the given template will only launch two instances — one seed and one not seed. Bootstrapping a cluster might be a very time consuming activity, but it can be mitigated with automation — a Jenkins job to deploy the cluster and also upscale it to 3 instances in total.</p><iframe src="" width="0" height="0" frameborder="0" scrolling="no"><a href="https://medium.com/media/1bf6055158844841e224bd8172814951/href">https://medium.com/media/1bf6055158844841e224bd8172814951/href</a></iframe><p>Due to the way ScyllaDB <a href="https://opensource.docs.scylladb.com/stable/operating-scylla/procedures/cluster-management/add-node-to-cluster.html">replicates data</a> the cluster can only be increased one node at a time. By automating this process, you can ensure that nodes are started sequentially, maintaining data consistency and cluster health. You can extend the Jenkins job to add more nodes, but in this case don’t forget to add the checks to guarantee that all ScyllaDB nodes are up and running.</p><h3><strong>Afterwards</strong></h3><p>In the ever-evolving world of cloud computing, the allure of AWS managed services can be irresistible. They offer convenience, scalability, and reliability for your projects. However, as we’ve explored in this article, it’s essential to keep a watchful eye on costs.</p><p>While AWS services can kickstart your project with ease, their pricing models may not be as friendly to your budget in the long run. In the pursuit of efficiency and cost-effectiveness, it’s worth considering open-source alternatives.</p><p>Embracing open-source solutions like ScyllaDB may require a bit more setup and ongoing maintenance, but the benefits can be substantial. You gain more control over your infrastructure, avoid unforeseen cost spikes, and often achieve better performance.</p><p>So, the next time you embark on a cloud journey, remember that striking the right balance between managed services and open source can be the key to success. It might be “harder” in some aspects, but in the end, it’s a strategic move that can yield superior results for your projects and your budget alike.</p><h3>Support</h3><p>If you like the content you read and want to support the author — thank you very much!<br>Here is my Ethereum wallet for tips:<br><strong>0xB34C2BcE674104a7ca1ECEbF76d21fE1099132F0</strong></p><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=6778f2c4ad47" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>