Clean history, git LFS
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.PNG filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.JPG filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.MP4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Build & Push Hugo Site Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- feature/external-media
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
containerize:
|
||||
name: 'containerize'
|
||||
env:
|
||||
REGISTRY_USERNAME: nicholas
|
||||
IMAGE_REGISTRY: git.uuard.com
|
||||
IMAGE_NAME: nicholas/web-log
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
|
||||
- name: ⚙️ Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v3
|
||||
with:
|
||||
hugo-version: '0.147.9'
|
||||
extended: true
|
||||
|
||||
- name: 🏗️ Build Hugo site
|
||||
working-directory: container/site
|
||||
run: hugo --minify
|
||||
|
||||
- name: 🔐 Gitea Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.IMAGE_REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REPO_PKG_TOKEN }}
|
||||
|
||||
- name: 🐋 Build Docker image
|
||||
run: docker build -t $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG ./container
|
||||
|
||||
- name: 🚀 Push Docker image
|
||||
run: docker push $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
|
||||
|
||||
- name: 🧹 Prune images
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/hugo
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=hugo
|
||||
|
||||
### Hugo ###
|
||||
# Generated files by hugo
|
||||
container/site/public/
|
||||
container/site/resources/_gen/
|
||||
container/site/assets/jsconfig.json
|
||||
container/site/hugo_stats.json
|
||||
|
||||
# Executable may be added to repository
|
||||
hugo.exe
|
||||
hugo.darwin
|
||||
hugo.linux
|
||||
|
||||
# Temporary lock file while building
|
||||
container/site/.hugo_build.lock
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/hugo
|
||||
|
||||
/.vscode/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "container/site/themes/LoveIt"]
|
||||
path = container/site/themes/LoveIt
|
||||
url = https://github.com/sulrich/LoveIt.git
|
||||
@@ -0,0 +1,42 @@
|
||||
# web log
|
||||
This is my personal blog.
|
||||
|
||||
## Tech Stack
|
||||
- [Hugo](https://gohugo.io/)
|
||||
- [Markdown](https://daringfireball.net/projects/markdown/)
|
||||
- Submodules:
|
||||
- [LoveIt theme](https://github.com/dillonzq/LoveIt)
|
||||
|
||||
### clone repository
|
||||
To clone the repo and its submodules in one step:
|
||||
```shell
|
||||
git clone --recurse-submodules https://git.uuard.com/nicholas/web-log.git
|
||||
```
|
||||
or
|
||||
|
||||
```shell
|
||||
git clone https://git.uuard.com/nicholas/web-log.git
|
||||
cd web-log/themes
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### update submodules
|
||||
```shell
|
||||
git submodule update --remote --merge`
|
||||
```
|
||||
|
||||
### development server
|
||||
```shell
|
||||
hugo server
|
||||
```
|
||||
|
||||
### production
|
||||
I have configured the Hugo site to serve media assets (images, videos) from an external host. To enable this, I have:
|
||||
|
||||
- Defined a base URL for file server in my `hugo.toml`:
|
||||
|
||||
`filesBase = "https://files.web-log.uuard.com"`
|
||||
- Created custom shortcodes (gallery, video, image) that:
|
||||
- Accept a relative path (e.g. `"videos/fells-point-path.webm"`)
|
||||
- Resolve the full asset URL using the `filesBase` parameter
|
||||
- Use the full URL in (e.g. `<video>`, `<img>`) HTML elements
|
||||
@@ -0,0 +1,7 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
|
||||
COPY site/public /usr/share/nginx/html/
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,5 @@
|
||||
+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
||||
@@ -0,0 +1,13 @@
|
||||
+++
|
||||
date = 2024-03-09T03:16:57Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "about"
|
||||
title = "About This Site"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
|
||||
This site was created using the static site generator `Hugo`.
|
||||
|
||||
This is a *web log*, not a *blog*, though the distinction exists only in my head, and serves as a way to distance myself from the word blog itself, which now seems to connote something odd I cannot quite put my finger on. Perhaps relevant is that I do not like portmanteaus. They reduce precision and create ambiguity by default. They have the feeling of whimsy and unseriousness.
|
||||
@@ -0,0 +1,104 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
date = 2024-04-27T05:30:14Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-04-maintenance"
|
||||
title = "🚗 Car Maintenance - Oil, Filter, Engine Coolant"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
|
||||
Labor is expensive and tools are cheap. These are the premises upon which I figure I should change the oil and oil filter myself. And I like to do things myself sometimes, it is not always about the cost savings. Maybe one day my body will fail me or I simply will not want to do it anymore. I am not totally committed to doing everything myself *all* the time.
|
||||
|
||||
## Engine Coolant
|
||||
The engine coolant leaks constantly, from where I do not know, very slowly, on the order of maybe less than a teaspoon a day. All attempts at stopping the leak have failed, which includes replacing a couple different hoses and fittings (I did myself), as well as some "repairs" completed by an auto repair shop. To be fair to myself, the leak rate does appear to have slowed noticeably since the problem appeared, so my own repairs were probably not totally in vain. I find additional comfort in knowing that these components would have failed eventually and I have therefore performed *preventative* maintenance . Also, a professional auto repair garage failed to properly diagnose and solve the problem too, so how bad is everything? Not bad at all. It is far cheaper to continue to top up with engine coolant every month than it is to continue to waste time and money on diagnostics and fixes.
|
||||
|
||||
{{< image
|
||||
src="engine-coolant-reservoir.jpg"
|
||||
caption="Engine coolant low because it leaks slowly" >}}
|
||||
|
||||
{{< image
|
||||
src="engine-coolant-refilled.jpg"
|
||||
caption="Engine coolant filled" >}}
|
||||
|
||||
|
||||
### Supply List
|
||||
|
||||
{{< image
|
||||
src="oil-change-setup.jpg"
|
||||
caption="Oil change supplies" >}}
|
||||
|
||||
|
||||
- 3/8" drive torque wrench
|
||||
- 3/8" drive 15/16" hex socket
|
||||
- 3/8" drive socket extender
|
||||
- 10mm socket (or 17mm socket) *
|
||||
- *ACDelco* PF2257Gr oil filter
|
||||
- SAE 5W-30 OIL 5 qt.
|
||||
- Funnel
|
||||
- Oil pan
|
||||
- Nitrile Gloves
|
||||
- Car ramps (2)
|
||||
- Wheel chocks (2)
|
||||
- Flat head screwdriver
|
||||
- Shop towels
|
||||
- Vegetable oil jug (for spent oil)
|
||||
- Engine Coolant
|
||||
|
||||
## Getting Under the Car
|
||||
### Jack Stands
|
||||
One issue with using jack stands is the potential for fatigue failure in the crimp welds. This type of weld is relatively weak – two pieces of sheet metal crimped together. Each time the car is lifted at the jack points, the welds are stressed and deform the metal. There is a finite number of times the jack point can be subjected to these stresses before dangerous cracks appear, and eventually total failure of the weld.
|
||||
|
||||
Another issue with the crimp welds that form the recommended jack points on the Chevy Cruze is its susceptibility to corrosion, especially in environments that would expose the welds to moisture and salt – they seep into the joints and cause corrosion over time. This issue is especially present in heavily-salted winter roads in the mid-west.
|
||||
|
||||
### Car Ramps
|
||||
I bought a pair of car ramps and wheel chocks. The reviews are great, no one seems to be getting crushed to death as a result of the product failing. It worked great. I was easily able to reach the oil drain plug, with enough vertical clearance for both the oil pan and the socket wrench.
|
||||
|
||||
{{< image
|
||||
src="car_ramp.jpg"
|
||||
caption="Car ramps product image" >}}
|
||||
|
||||
{{< image
|
||||
src="car_ramp_reviews.png"
|
||||
caption="Harbor Freight Tools car ramp reviews" >}}
|
||||
|
||||
{{< image
|
||||
src="oil-change-ramp.jpg"
|
||||
caption="Car ramp" >}}
|
||||
|
||||
|
||||
## Oil Change Procedure:
|
||||
1. Pull out the dipstick to vent the oil reservoir and prevent vacuum during drain.
|
||||
2. Place oil pan underneath oil plug.
|
||||
3. Remove oil plug using 17mm socket, allow oil to drain for some time.
|
||||
4. Remove oil filter housing with 15/16" socket. Use socket extender
|
||||
5. Remove oil filter from housing (pull)
|
||||
6. Remove old o-ring from filter housing
|
||||
7. Replace filter housing o-ring with new
|
||||
8. Install new filter in housing (press)
|
||||
9. Install filter assembly. Torque to just snug
|
||||
10. When oil is finished draining, replace oil plug (~6Nm)
|
||||
11. Open oil reservoir cap
|
||||
12. Use funnel to pour in ~3.5 quarts. Check dip stick.
|
||||
13. Continue to add oil until dip stick shows oil between the hatch markings.
|
||||
14. Replace oil cap.
|
||||
15. Dispose of spent oil.
|
||||
|
||||
{{< image
|
||||
src="oil-change-plug.jpg"
|
||||
caption="Oil drain plug" >}}
|
||||
|
||||
{{< image
|
||||
src="dipstick.jpg"
|
||||
caption="Dipstick indicator" >}}
|
||||
|
||||
{{< image
|
||||
src="oil-change-old-oil-funnel.jpg"
|
||||
caption="Spent oil collected in oil pan" >}}
|
||||
|
||||
{{< image
|
||||
src="oil-change-old-oil.jpg"
|
||||
caption="Spent oil" >}}
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,57 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2024-12-20T10:30:00Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-christmas-eve-appetizer-party"
|
||||
title = "🎄2024 Christmas Eve Party Appetizers"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
The trick is to select a food that will stay viable through the 2 hour drive, plus the interim time after it is finished being prepared (Some amount of built-in margin for unexpected delays is prudent). Without catering tools this is surprisingly difficult, especially for someone probably too concerned about food-borne illnesses. This is a dark but comforting thought: if someone(s) *did* get sick it is unlikely that they would be able to determine the offending food. The sample size is small, hopefully especially small for those who *did* develop the illness. Plenty of plausible deniability since they probably ate other foods too.
|
||||
|
||||
Basically, we need to choose a food based on two criteria:
|
||||
- How well the food "keeps", i.e. how well the freshness, texture, flavor, and overall quality is preserved after being prepared and stored
|
||||
- How well the food "travels", i.e. durability, food-safe temperature requirements, etc.
|
||||
|
||||
## Pretzel Nuggets
|
||||
We made pretzel nuggets and traditional beer cheese for a family Christmas Eve gathering. We made two variations of the pretzel nuggets: *plan (salted)*, and *cinnamon sugar*.
|
||||
|
||||
### Cinnamon Sugar ★★★☆☆
|
||||
This one was disappointing, the sugars eventually dissolved and made a sticky syrup which ruined the look. This was probably made worse by applying the sugar too soon, while the nuggets were still warm and moisture was still rapidly migrating to the surface, acting as a solvent for the sugar. They still tasted excellent.
|
||||
|
||||
### Plain (salted) ★★★★☆
|
||||
These were very good, if a little stale by the time they were eaten ~4 hours later, but this is to be expected for a baked product containing no fat and high surface area-to-volume ratio. This must be part of why Auntie Anne's prefers to bake pretzels "on demand" (duh).
|
||||
|
||||
## Beer Cheese ★★☆☆☆
|
||||
My poor opinion of this recipe and resultant food depends much on the selection of ingredients and preparation. The beer we selected was too bitter, and temperature of the cheese was too low (room temperature). It became grainy and sludge-like because it was thickened with flour. Since I did not have any tested recipes that would fill the need for pretzel dip, I was aiming to be *traditional* by picking a *traditional* beer cheese recipe, thinking that if it was *traditional* it would be well-tested and *good* having survived many years. Next time I will just melt down some American cheese into a pot of milk with mega-brand beer and this will probably be superior.
|
||||
|
||||
### Beer
|
||||
|
||||
{{< image
|
||||
src="images/piney-river-missouri-mule-ipa.PNG"
|
||||
caption="Missouri Mule IPA" >}}
|
||||
|
||||
I do not know much about beer, and what I *do* know is not quite relevant to drinking it, or consuming it otherwise, so I am not in a position to evaluate the quality of the beer I selected. I am however, able to evaluate the subjective elements of a beer, namely taste (it was bad). It smelled odd, and it left a bad aftertaste. Apparently the beer is made with filtered water sourced from a well. This is marketed as "Ozarks water".
|
||||
|
||||
> “We’ve always had excellent Ozarks water to capture our sense of ‘place’ in our beer, and we’ve used locally harvested and grown ingredients in some beers, too”
|
||||
>
|
||||
> — **Brian Durham**, Founder & Owner Piney River Brewing Co.
|
||||
|
||||
I read their site for one minute and already I see what they are trying to do and I do not like it. Their marketing is not built for me. "...capture our sense of 'place'...". I insist that that "place" was a barnyard. I am sick to death of the kitschy pretentiousness, of their gimmicks and their saccharine anemoia.
|
||||
|
||||
## Other appetizers attending
|
||||
- stuffed mushroom
|
||||
- cheese balls, various
|
||||
- pineapple upside down cake
|
||||
- pig in a blanket
|
||||
- spinach roll-ups
|
||||
- shrimp
|
||||
- little smokies
|
||||
- chocolate pie
|
||||
- chick-fil-a nuggets
|
||||
- etc.
|
||||
|
||||
## Ideas for next time
|
||||
- Quiche - Caramelized Onion, Bacon, Cheese
|
||||
- Christmas Focaccia
|
||||
@@ -0,0 +1,18 @@
|
||||
images:
|
||||
- src: "images/herb-garden/herb-garden-enclosure.jpg"
|
||||
alt: "Raised bed herb garden enclosure made of wood and plastic sheeting"
|
||||
caption: "Herb garden enclosure"
|
||||
- src: "images/herb-garden/herb-garden-baby-basil.JPG"
|
||||
alt: "Small basil plant growing"
|
||||
caption: "Baby basil"
|
||||
- src: "images/herb-garden/herb-garden-tomato-and-basil.jpg"
|
||||
alt: "Tomato and basil in herb garden"
|
||||
caption: "Tomato and basil growing together"
|
||||
- src: "images/herb-garden/apartment-view-herb-garden.jpg"
|
||||
alt: "Herb garden viewed through glass door"
|
||||
caption: "Herb garden outside"
|
||||
- src: "images/herb-garden/basil-overgrown.jpg"
|
||||
alt: "Overgrown raised planter"
|
||||
caption: "Lots of basil"
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
+++
|
||||
categories = ["plants"]
|
||||
date = 2024-04-23T13:53:30Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2024-garden"
|
||||
title = "🥕 2024 Garden"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
## Raised Bed Vegetable Garden
|
||||
In April 2023 I built a raised bed out of recycled deck boards that were harvested from a disassembled old project (a wooden deck mounted atop the latter rack of my truck). The original lumber was pressure-treated and stained, which in some ways was ideal and in other ways possibly bad. It was suitable for outdoor use and for contact with the ground on account of the treatments, which is great, but for the same reasons it was also a hazard for the many chemicals that *may* leech into the soil and *may* be absorbed into the plant. After some amount of research and consideration, we decided that we would be fine.
|
||||
|
||||
### Construction
|
||||
The garden bed is roughly 6' x 4' x 16" and contains three layers, presented in the same order found in the garden bed:
|
||||
- bagged topsoil from Home Depot.
|
||||
- 1 cubic yard layer of "topsoil" from a local compost business
|
||||
- mulch
|
||||
|
||||
#### Topsoil
|
||||
To acquire and transport the topsoil, we lined the bed of my truck with a thick plastic tarp and stood back to watch a skid steer operator dump the dirt precisely between the beams of the ladder rack. When we arrived back at the house, I backed the truck into the yard and positioned the truck so that the tailgate would overhang the garden bed, and the dirt could simply be shoveled to the rear. Once enough dirt was shoveled out, we could drag the tarp out of the back, bringing with it the remaining dirt. It was quick compared to any other method I could think of.
|
||||
|
||||
#### Growth Time-lapse
|
||||
Each time I visited to help water and prune, I tried to remember to photograph the planter. Later I would create a stack of the images in Photoshop and use the auto-alignment tool to give the time-lapse some smoothness. Below is the result. This coarse-grained, very short time-lapse covers the day the plants were transplanted (sometime in April), to mid-July when the first tomatoes fully ripened.
|
||||
|
||||
{{< video
|
||||
src="videos/garden-timelapse.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
## Photos
|
||||
### Vegetable Garden
|
||||
{{< gallerynew name="vegetable-garden-gallery" >}}
|
||||
|
||||
|
||||
## Herb Garden
|
||||
{{< gallerynew name="herb-garden-gallery" >}}
|
||||
@@ -0,0 +1,22 @@
|
||||
images:
|
||||
- src: "images/vegetable-garden/seedlings.JPG"
|
||||
alt: "Young tomato plants"
|
||||
caption: "Small tomato plant"
|
||||
- src: "images/vegetable-garden/bailey-tomatoes.jpg"
|
||||
alt: "Beautiful girl posing with plastic deli containers full of cherry tomatoes next to her head"
|
||||
caption: "Bailey's tomatoes"
|
||||
- src: "images/vegetable-garden/cherry-tomato.jpg"
|
||||
alt: "Several cherry tomatoes in hand"
|
||||
caption: "Ripe cherry tomatoes."
|
||||
- src: "images/vegetable-garden/eggplant.jpg"
|
||||
alt: "A plump eggplant"
|
||||
caption: "Eggplant"
|
||||
- src: "images/vegetable-garden/green-bell-pepper.jpg"
|
||||
alt: "A bright green bell pepper."
|
||||
caption: "Bell pepper."
|
||||
- src: "images/vegetable-garden/roma-tomato.jpg"
|
||||
alt: "3 bright red ripe Roma tomatoes"
|
||||
caption: "Roma tomatoes"
|
||||
- src: "images/vegetable-garden/tomato-pan.jpg"
|
||||
alt: "Tomatoes in a pan on cooking range"
|
||||
caption: "Making pasta sauce"
|
||||
@@ -0,0 +1,21 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
date = 2025-03-01T08:36:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2025-03-car-maintenance"
|
||||
title = "🚗 Oil Change & Engine Coolant"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Once again, I must change the oil and oil filter in my now *wife's / our* vehicle. I repeat the process documented in my previous post.
|
||||
|
||||
## Hazard of Too *Much* Oil
|
||||
I did not do as well as I did the first time. I poured in too much oil, an amount that just submerged the entire dipstick indicator. This *could* be very bad because if the oil is reachable by the crankshaft, it would whip the oil into foam. Aerated oil does not lubricate well. Through various forums I was reassured that the level of excess oil added probably, almost certainly, would not result in any kind of engine damage or be sufficient to reach the crankshaft, but I made sure to get the level between the hatches anyway by draining some oil from the drain plug beneath the car.
|
||||
|
||||
{{< image
|
||||
src="images/oil-dipstick.jpg"
|
||||
alt="Car engine oil dipstick indicator shows oil level is within acceptable levels"
|
||||
caption="Perfect dipstick" >}}
|
||||
|
||||
It was much easier this time, as can be expected. Done.
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
+++
|
||||
categories = ["plants"]
|
||||
date = 2025-02-20T10:30:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "2025-garden"
|
||||
title = "🍃 2025 Garden"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
It is nearly springtime as I write the first lines of this post, so it is time to prepare the gardens. This involves raising some seedlings. This year, there will be two gardens:
|
||||
- An **herb** garden
|
||||
- A **vegetable** garden
|
||||
|
||||
## Starting Seeds
|
||||
{{< gallerynew name="seed-gallery" >}}
|
||||
|
||||
## Seedlings
|
||||
The seeds were started in little clumps of coco coir pellets and kept beneath a LED grow light.
|
||||
|
||||
{{< gallerynew name="seedling-gallery" >}}
|
||||
|
||||
## Herb Garden
|
||||
The herb garden lives outside on the deck. It will contain garlic chives and basil. They make great pizza toppings and garnishes.
|
||||
### Garlic Chives
|
||||
### Basil
|
||||
## Vegetable Garden
|
||||
The herb garden will contain tomatoes and green onions.
|
||||
### Tomato
|
||||
@@ -0,0 +1,16 @@
|
||||
images:
|
||||
- src: "images/garden-seeds.jpg"
|
||||
alt: "Seed packets"
|
||||
caption: "Seed packets"
|
||||
- src: "images/coco-coir-pellets.JPG"
|
||||
alt: "Coco coir pellets"
|
||||
caption: "Coco coir pellets"
|
||||
- src: "images/seedling-pots.JPG"
|
||||
alt: "Seedling pots"
|
||||
caption: "Seedling pots"
|
||||
- src: "images/seedling-pots-filled.JPG"
|
||||
alt: "Seedling pots filled"
|
||||
caption: "Seedling pots filled"
|
||||
- src: "images/seedling-tray-labels.JPG"
|
||||
alt: "Seedling tray labels"
|
||||
caption: "Seedling tray labels"
|
||||
@@ -0,0 +1,10 @@
|
||||
images:
|
||||
- src: "images/seedlings-sunlight.JPG"
|
||||
alt: "Seedlings in the sun"
|
||||
caption: "Seedlings in the sun"
|
||||
- src: "images/seedlings-beneath-grow-light.JPG"
|
||||
alt: "Seedlings under grow light"
|
||||
caption: "Seedlings under grow light"
|
||||
- src: "images/seedling-purple-basil.JPG"
|
||||
alt: "Purple basil seedling"
|
||||
caption: "Purple basil seedling"
|
||||
@@ -0,0 +1,15 @@
|
||||
+++
|
||||
date = 2024-03-09T03:16:57Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "about"
|
||||
title = "About this web log"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
|
||||
This site is built with a static site generator `Hugo` with the `LoveIt` theme.
|
||||
|
||||
This is a web log, not a blog, though the distinction exists only in my head, and serves as a way to distance myself from the word blog itself, which now seems to connote something odd I cannot quite put my finger on. Perhaps relevant is that I do not like portmanteaus. They reduce precision and create ambiguity by default. They have the feeling of whimsy and unseriousness – sort of like the product of a marketing department's monomaniacal pursuit of capturing even a tiny additional scrap of mindshare, without respect at all for the words it clumsily hot-glues together. Something like this.
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
images:
|
||||
- src: "images/double-decker-pizza.jpg"
|
||||
alt: "Two pizzas stacked on top of eachother"
|
||||
caption: "Double-decker pizza"
|
||||
- src: "images/hawaiin-papperoni.jpg"
|
||||
alt: "Hawaiin and pepperoni pizza"
|
||||
caption: "Hawaiin pepperoni"
|
||||
- src: "images/misc-pizza.jpg"
|
||||
alt: "Pizza with a lot of toppings"
|
||||
caption: "Misc pizza"
|
||||
- src: "images/pepperoni-pizza.jpg"
|
||||
alt: "Pepperoni pizza"
|
||||
caption: "Pepperoni pizza"
|
||||
- src: "images/sort-of-supreme-pizza.jpg"
|
||||
alt: "Supreme pizza"
|
||||
caption: "Sort of supreme pizza"
|
||||
@@ -0,0 +1,17 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2024-10-16T20:27:44Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "american-pizza"
|
||||
title = "🍕 American Pizza"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
> *Get'cha pizza, it's mighty good!*
|
||||
>
|
||||
> — **Old man**, passing encounter at Sam's Club
|
||||
|
||||
## Photos of my Pizzas
|
||||
{{< gallerynew name="american-pizza-gallery" >}}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
images:
|
||||
- src: "images/architecture.JPG"
|
||||
alt: "Architecture"
|
||||
caption: "Architecture"
|
||||
- src: "images/bailey-and-squirrel.JPG"
|
||||
alt: "Bailey and squirrel"
|
||||
caption: "Bailey and squirrel"
|
||||
- src: "images/baltimore-from-hotel-roof.jpg"
|
||||
alt: "Baltimore from hotel roof"
|
||||
caption: "Baltimore from hotel roof"
|
||||
- src: "images/baltimore-highway.jpg"
|
||||
alt: "Baltimore highway"
|
||||
caption: "Baltimore highway"
|
||||
- src: "images/camden-yards.jpg"
|
||||
alt: "Camden yards"
|
||||
caption: "Camden yards"
|
||||
- src: "images/conservatory-entrance.JPG"
|
||||
alt: "Conservatory entrance"
|
||||
caption: "Conservatory entrance"
|
||||
- src: "images/conservatory-orchids.JPG"
|
||||
alt: "Conservatory orchids"
|
||||
caption: "Conservatory orchids"
|
||||
- src: "images/conservatory.JPG"
|
||||
alt: "Conservatory"
|
||||
caption: "Conservatory"
|
||||
- src: "images/koi-pond.JPEG"
|
||||
alt: "Koi pond"
|
||||
caption: "Koi pond"
|
||||
- src: "images/koi-pond.JPG"
|
||||
alt: "Koi pond"
|
||||
caption: "Koi pond"
|
||||
- src: "images/lighthouse.JPEG"
|
||||
alt: "Lighthouse"
|
||||
caption: "Lighthouse"
|
||||
- src: "images/monument.JPG"
|
||||
alt: "Monument"
|
||||
caption: "Monument"
|
||||
- src: "images/museum-painting.JPG"
|
||||
alt: "Museum painting"
|
||||
caption: "Museum painting"
|
||||
- src: "images/museum-statues-room.JPG"
|
||||
alt: "Museum statues room"
|
||||
caption: "Museum statues room"
|
||||
- src: "images/museum-statues.JPG"
|
||||
alt: "Museum statues"
|
||||
caption: "Museum statues"
|
||||
- src: "images/orchid.JPG"
|
||||
alt: "Orchid"
|
||||
caption: "Orchid"
|
||||
- src: "images/park.JPG"
|
||||
alt: "Park"
|
||||
caption: "Park"
|
||||
- src: "images/peabody-library-front.JPG"
|
||||
alt: "Peabody library front"
|
||||
caption: "Peabody library front"
|
||||
- src: "images/peabody-library-side.JPG"
|
||||
alt: "Peabody library side"
|
||||
caption: "Peabody library side"
|
||||
- src: "images/roof-view-looking-down.JPG"
|
||||
alt: "Roof view looking down"
|
||||
caption: "Roof view looking down"
|
||||
- src: "images/squirrel-munch.JPG"
|
||||
alt: "Squirrel munch"
|
||||
caption: "Squirrel munch"
|
||||
- src: "images/squirrel-posing.JPG"
|
||||
alt: "Squirrel posing"
|
||||
caption: "Squirrel posing"
|
||||
- src: "images/squirrel-profile.JPG"
|
||||
alt: "Squirrel profile"
|
||||
caption: "Squirrel profile"
|
||||
- src: "images/stained-glass.JPG"
|
||||
alt: "Stained glass"
|
||||
caption: "Stained glass"
|
||||
- src: "images/two-ducks-pond.JPG"
|
||||
alt: "Two ducks pond"
|
||||
caption: "Two ducks pond"
|
||||
@@ -0,0 +1,31 @@
|
||||
+++
|
||||
categories = ["travel"]
|
||||
date = 2025-06-07T12:09:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "baltimore"
|
||||
title = "🏫 Baltimore, MD"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Bailey is enrolled in a Master's program at Johns Hopkins School of Public Health. She has been awarded a prestigious fellowship, which provides a generous stipend for school expenses and tuition paid for. The important bit is that the trip will in effect be paid for by the stipend, so I am happy to go -- I would not choose to go here otherwise. This trip is meant to satisfy part of the fellowship requirement to attend in-person courses, so it is not exactly a vacation.
|
||||
|
||||
`05-26-2025 to 06-06-2025`
|
||||
|
||||
## Residence Inn
|
||||
|
||||
{{< video
|
||||
src="videos/residence-inn.webm"
|
||||
width="100%"
|
||||
autoplay="true"
|
||||
loop="true" >}}
|
||||
|
||||
## Fells Point walk
|
||||
{{< video
|
||||
src="videos/fells-point-path.webm"
|
||||
width="100%"
|
||||
autoplay="true"
|
||||
loop="true" >}}
|
||||
|
||||
## Photo Gallery
|
||||
{{< gallerynew name="baltimore-gallery" >}}
|
||||
@@ -0,0 +1,28 @@
|
||||
images:
|
||||
- src: "images/banoffee-pie-banana-layer.JPG"
|
||||
alt: "Banoffee pie banana layer"
|
||||
caption: "Banoffee pie banana layer"
|
||||
- src: "images/banoffee-pie-bananas.JPG"
|
||||
alt: "Banoffee pie bananas"
|
||||
caption: "Banoffee pie bananas"
|
||||
- src: "images/banoffee-pie-caramel-layer.JPG"
|
||||
alt: "Banoffee pie caramel layer"
|
||||
caption: "Banoffee pie caramel layer"
|
||||
- src: "images/banoffee-pie-complete.JPG"
|
||||
alt: "Banoffee pie complete"
|
||||
caption: "Banoffee pie complete"
|
||||
- src: "images/banoffee-pie-pecan-layer.JPG"
|
||||
alt: "Banoffee pie pecan layer"
|
||||
caption: "Banoffee pie pecan layer"
|
||||
- src: "images/peel-banana.JPG"
|
||||
alt: "Peel banana"
|
||||
caption: "Peel banana"
|
||||
- src: "images/slice-banana.JPG"
|
||||
alt: "Slice banana"
|
||||
caption: "Slice banana"
|
||||
- src: "images/toasted-pecans.JPG"
|
||||
alt: "Toasted pecans"
|
||||
caption: "Toasted pecans"
|
||||
- src: "images/white-chocolate-pumpkin-pie.jpg"
|
||||
alt: "White chocolate pumpkin pie"
|
||||
caption: "White chocolate pumpkin pie"
|
||||
@@ -0,0 +1,24 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2025-03-01T06:27:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "banoffee-pie"
|
||||
title = "🍌 Banoffee Pie"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
We made a pumpkin caramel pie to bring for a family dinner (not pictured, strangely). Enough pie crust remained to line a shallow pizza pan, which I baked into a crust before I knew what it would become. We also had a bit of caramel leftover. After a little time considering what ingredients we had, Bailey figured we could make a decent banoffee pie. It is a beautiful accident that she had been wanting to make this after seeing it on *The Great British Bake Off*, and luckier still that we had bananas on hand.
|
||||
|
||||
{{< gallerynew name="banoffee-pie-gallery" >}}
|
||||
|
||||
## Recipe
|
||||
This is a lazy recipe because the food was assembled ad-hoc using leftovers of unknown measurements as is the case for many kinds of pies. I use the examples of the quiche and pie to make my point: they serve as the uniting force that brings together really any ingredient in any amount that you happen to have lying around. Details unimportant.
|
||||
|
||||
1. Bake pie crust ~12"
|
||||
2. Pour shallow layer of wet caramel
|
||||
3. Toast about 35 pecans
|
||||
4. Cover caramel layer with one layer of toasted pecans
|
||||
5. Slice a banana into 1/4" slices
|
||||
6. Cover the pecans in layer of whipped cream
|
||||
7. Place the banana slices over the whipped cream
|
||||
@@ -0,0 +1,134 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
tags = ["bicycle"]
|
||||
date = 2025-07-04T19:00:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "bicycle-brakes-replacement"
|
||||
title = "🚲 Bicycle Brakes Replacement"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
## ⚠️ Bad Brakes
|
||||
|
||||
The brakes on my **Trek FX 2 Disc Gen 3** are not working well, and are of low quality generally. The main problem is the front brake lever piston push rod that is meant to convey the force of the brake lever to the hydraulic fluid came free from its socket. The brake still works, provided that the linkage is carefully positioned. Otherwise if it is out of its socket, it will not link the brake lever to the hydraulic lines and there will be no braking. This is adequate, most of the time (except that the brakes need to be bled constantly and are just not very good brakes), but brakes are too important to be merely *adequate, most of the time*. Both the front and rear will be replaced. Again, not totally necessary since each of the brakes still technically work.
|
||||
|
||||
|
||||
{{< image src="images/rush-piston-push-rod-broken.JPG" caption="Push rod disconnected" >}}
|
||||
|
||||
|
||||
When we bought this bike used on Facebook Marketplace, we did not notice the brakes were different than those [specified on the Trek website](#-bike-specifications).
|
||||
|
||||
**From the Trek website:**
|
||||
> Trek reserves the right to change specs, models, and prices without notice.
|
||||
|
||||
|
||||
Supposedly, due to supply chain issues during the Covid-19 era Trek was forced to substitute the (preferred, presumably) specified Taiwanese **Tektro HD-R280** brakes for a little-known Chinese brand, formerly Taiwanese brand: **Rush**, a brand of **Ningbo C.STAR Technology Co. LTD** (??? 😟).
|
||||
|
||||
{{< image src="images/rush-ningbo-cstar-technology-co-ltd.png" caption="😟" >}}
|
||||
|
||||
## 👎🏻 Old Brakes
|
||||
{{< image src="images/rush-brake-front.JPG" caption="Rush brakes - front" >}}
|
||||
{{< image src="images/rush-brake-rear.JPG" caption="Rush brakes - rear" >}}
|
||||
|
||||
|
||||
## ✅ New Brakes
|
||||
I will not replace them with the spec'd **Tektro HD-R280**. Instead, as far as I can tell, I will be *upgrading* to **Shimano MT201-UR300**.
|
||||
|
||||
{{< image src="images/shimano-mt201-ur300.png" caption="Shimano MT201-UR300" >}}
|
||||
{{< image src="images/shimano-brake-front.JPG" caption="Shimano brakes - front" >}}
|
||||
{{< image src="images/shimano-brake-rear.JPG" caption="Shimano brakes - rear" >}}
|
||||
|
||||
## 📋 Process
|
||||
I intended to document the process. I forgot.
|
||||
|
||||
### 🧾 Notes
|
||||
The process went smoothly except in a couple of steps. I had a few problems:
|
||||
- Tape failing while pulling the string and hose through the frame (hose coated in mineral oil hydraulic fluid makes for weak tape adhesion).
|
||||
- String not pulling from the very center of the hose, pull angle offset from the hose angle. This offset was large enough and the margins afforded by the cable routing grommet small enough, that the hose could not pass through.
|
||||
- Sometimes the layer of tape was too thick.
|
||||
|
||||
My final solution involved inserting a metal coat hanger wire into the hydraulic hose, bending it into a loop and compressing the free end of the coat hanger wire into the hose, fastening the wire to the hose with friction. This allowed me to tie a string to the loop and pull the string through the frame with relative ease. It solved all of my problems. Since I had not trimmed the hydraulic line to size yet, I had much more hose than I needed and could afford to sacrifice some of it in compressing the metal loop into the hose.
|
||||
|
||||
- Mineral oil somehow made it onto the brake pads. This caused obnoxiously loud brakes. Solved by dunking the pads in isopropyl alcohol.
|
||||
|
||||
✅ Done.
|
||||
|
||||
|
||||
## 🚴♂️ Bike Specifications
|
||||
|
||||
> _Applies to all sizes unless listed separately_
|
||||
|
||||
---
|
||||
|
||||
### **Frameset**
|
||||
|
||||
| Component | Specification |
|
||||
|----------------|-----------------------------------------------------------------------------------------------|
|
||||
| **Frame** | Alpha Gold Aluminum, DuoTrap S compatible, internal cable routing, flat mount disc, rack & fender mounts, 135x5mm QR |
|
||||
| **Fork** | FX Alloy, flat mount disc, rack mounts, 405 mm axle-to-crown, ThruSkew 5mm QR |
|
||||
|
||||
---
|
||||
|
||||
### **Wheels**
|
||||
|
||||
| Component | Specification |
|
||||
|------------------|----------------------------------------------------------------------------------------|
|
||||
| **Front Hub** | Formula DC-20, alloy, 6-bolt, 5x100mm QR |
|
||||
| **Front Skewer** | 130x5mm QR, ThruSkew |
|
||||
| **Rear Hub** | Formula DC-22, alloy, 6-bolt, Shimano 8/9/10 freehub, 135x5mm QR |
|
||||
| **Rear Skewer** | 152x5mm QR |
|
||||
| **Rims** | Bontrager Connection, double-wall, 32-hole, 20mm width, Schrader valve |
|
||||
| **Tires** | Bontrager H2 Comp, wire bead, 30 TPI, 700x35mm |
|
||||
| **Max Tire Size**| 700x38mm (without fenders), 700x35mm (with fenders) |
|
||||
|
||||
---
|
||||
|
||||
### **Drivetrain**
|
||||
|
||||
| Component | Specification |
|
||||
|--------------------|---------------------------------------------------------------|
|
||||
| **Shifter** | Shimano Altus M2010, 2x9 speed |
|
||||
| **Front Derailleur**| Shimano Acera T3000, 34.9mm clamp, top swing, dual pull |
|
||||
| **Rear Derailleur** | Shimano Altus M2000, long cage |
|
||||
| **Crank** | Forged alloy, 46/30, chainguard, 170mm length |
|
||||
| **Bottom Bracket** | Sealed cartridge, 68mm |
|
||||
| **Cassette** | Shimano HG200, 11-36T, 9-speed |
|
||||
| **Chain** | KMC X9 |
|
||||
| **Pedals** | Bontrager City Pedals |
|
||||
| **Max Chainring Size** | 1x: 42T, 2x/3x: 48T |
|
||||
|
||||
---
|
||||
|
||||
### **Components**
|
||||
|
||||
| Component | Specification |
|
||||
|-------------------|-------------------------------------------------------------------------------------------------|
|
||||
| **Saddle** | Bontrager Sport |
|
||||
| **Seatpost** | Bontrager alloy, 27.2mm, 12mm offset, 330mm length |
|
||||
| **Handlebar** | XS–M: Bontrager alloy, 31.8mm, 15mm rise, 600mm width<br>L–XXL: Bontrager alloy, 31.8mm, 15mm rise, 660mm width |
|
||||
| **Grips** | Bontrager Satellite |
|
||||
| **Stem** | XS–M: Bontrager Comp, 31.8mm, Blendr compatible, 7°, 90mm<br>L–XXL: Bontrager Comp, 31.8mm, Blendr compatible, 7°, 100mm |
|
||||
| **Headset** | 1 1/8" steel threadless |
|
||||
| **Brake Set** | Tektro HD-R280 hydraulic disc, flat mount, 160mm rotors |
|
||||
| **Max Rotor Size** | 160mm front & rear |
|
||||
|
||||
---
|
||||
|
||||
### **Weight**
|
||||
|
||||
| Component | Specification |
|
||||
|-------------------|----------------------------------|
|
||||
| **Weight (M Size)** | 11.73 kg / 25.87 lbs |
|
||||
| **Weight Limit** | 300 lbs (136 kg) — includes bike, rider, and cargo |
|
||||
|
||||
---
|
||||
|
||||
### **Additional Notes**
|
||||
|
||||
- Trek reserves the right to change specs, models, and prices without notice.
|
||||
- Due to supply chain issues, compatible substitute parts may be used.
|
||||
- Weights listed are based on pre-production painted frames and may vary in final production.
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
+++
|
||||
categories = ["build"]
|
||||
date = 2024-10-16T20:27:44Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "bicycle-pump-sprayer"
|
||||
title = "🚲 Bicycle Washing Contraption"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I lack a method of washing my bike that does not have the potential to disturb my neighbors. The wooden deck of my third floor apartment sits directly above each neighbors' deck. To spell it out, if I wash my bike outside on my deck, the water, soap, dirt, grit and grime that results will run off of my deck and spill onto the neighbors'. I try to be a good neighbor, so this will not do. The bathtub is not big enough, and that is no place to wash a bike anyway. There are no good options remaining – I must wash my bike outdoors.
|
||||
|
||||
## Solutions
|
||||
Basically, my next obstacle is solving the distillation of the original problem: find a sensible way to transport water to my bike outside. My options are to take the water with me to ground level, or feed it down through a hose.
|
||||
|
||||
### Hose
|
||||
I can run a hose from the kitchen faucet, or the supply line, to the ground level, snaking it through the back door and beneath the deck to the ground. This can work alright, but the solution presents several issues. It requires that I buy a 50 foot hose, a faucet adapter, and a nozzle. Additionally, each time I wash my bike I need to remove the faucet nozzle, attach the adapter, attach the hose, unwind the hose, route it through the apartment and onto the ground and finally turn on the faucet. And when I am done I need to do it all again in reverse, all while the kitchen sink is out of commission for any other purpose. I could attach the hose to the water line that feeds the kitchen faucet, but this only solves the concurrent use issue. There is the problem of stagnant water sitting in the hose, which itself does not present a problem necessarily, but I fear this may smell or somehow corrupt the atmosphere in the apartment. Also, I need to leave the door open to unwelcome bugs, humidity, and changes in temperature. There are many issues I have not thought of. This is not a great option.
|
||||
|
||||
### Pump Sprayer
|
||||
My next option is a plastic pump sprayer. Theoretically this solves or addresses every issue with option 1. No open doors or fiddling with adapters or routing hoses around the apartment. The only problem seems to be that the cheap ones are universally terrible products, at least according to a worrying portion of the reviews. Their problem is that they are prone to leaking and have durability issues. This is not a big deal, but I believe I have a more robust solution. I have mostly everything I need to fashion a pump sprayer of my own.
|
||||
|
||||
### Bike Pump and a Keg (winner)
|
||||
#### Topo Chico solved my problem years ago
|
||||
I am fond of the carbonated beverage Topo Chico. During the initial phase of my obsession I tried to find a way to recreate it myself. To shorten what could be another log post, this decision lead me down a path resulting in me owning all the equipment necessary to carbonate and dispense my own beverages. For years I had all the carbonated water I could drink, fresh and cold on a tap. I cannot remember why I stopped making it. The city water supply was full of minerals, too. Pretty close:
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/topo-chico-minerals.PNG"
|
||||
caption="Topo Chico minerals" >}}
|
||||
|
||||
{{< image
|
||||
src="images/city-minerals.PNG"
|
||||
caption="City minerals" >}}
|
||||
|
||||
#### Keg
|
||||
Importantly though, my Topo Chico reproduction means I own a keg. Inside the keg there is a long tube that runs from the bottom of the tank to an outlet valve. The inlet valve accepts gas, that, when pressurized (with, for example, a bike pump), pushes the liquid up the straw, out the valve, and through the hose. Under the water-carbonation regime, the keg pressure is supplied by a carbon dioxide tank and regulator, but the pump will perform the same functions. All I need to do is find a way to pump air into the inlet valve – everything else is already suitably configured. The keg holds 1.6 L of water, which I have found, to the eye and on paper, a deceivingly large volume of water. Enough to get the job done, but not more.
|
||||
|
||||
## Parts
|
||||
| Part | Purpose |
|
||||
| ---------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 1.6 L Ball Lock Keg | Pressurized Water Vessel |
|
||||
| Worm Drive Hose Clamp | Fasten hoses to barbs |
|
||||
| Bicycle Pump | Pressurize water vessel |
|
||||
| 1/4" M Threaded Snifter (Schrader) Air Valve Fitting | Valve interfaces with bicycle pump, fitting threads into barb fitting |
|
||||
| 1/4" F Threaded Barb Fitting | Barb accepts 5/16" gas line, fitting threads accepts Schrader valve fitting |
|
||||
| 5/16" ID Gas hose | Carries pressurized air from bicycle pump through ball lock valves, to the keg |
|
||||
| 3/16" ID Liquid Hose | Carries pressurized water from keg through ball lock valves, to picnic valve |
|
||||
| Picnic Faucet | Dispense water |
|
||||
|
||||
{{< image
|
||||
src="images/picnic-faucet.jpg"
|
||||
caption="Picnic faucet & hose" >}}
|
||||
|
||||
All of the parts listed above I already had. I only needed the two fittings below. I bought these parts at a local Ace Hardware store. The barb fits into the gas hose, which has an inner diameter that matches the outer diameter of the barb. This fitting is secured with a worm drive clamp. This is a common configuration for securing gas tubing to metal fittings. The other end of the barb is a 1/4" female threaded fitting, which accepts the 1/4" male thread of the Schrader valve. Each of these fittings fall under the National Pipe Thread standard, so I do not need to worry about thread pitch, TPI, etc. I simply match the relevant numbers up. I use a bit of teflon tape to ensure air tight fit.
|
||||
|
||||
The other end of the gas tube is fitted with a female ball lock valve, which accepts the male ball lock valve that is affixed to the keg. The ball lock valve is common in home/craft beer brewing systems and other small scale beverage production. It is excellent for quickly connecting/disconnecting and thus for my purpose of modularity and convenience.
|
||||
|
||||
{{< image
|
||||
src="images/barb.PNG"
|
||||
caption="Barb" >}}
|
||||
|
||||
{{< image
|
||||
src="images/valve.PNG"
|
||||
caption="Valve" >}}
|
||||
|
||||
{{< image
|
||||
src="images/ball-lock-schrader.jpg"
|
||||
caption="ball lock" >}}
|
||||
|
||||
## Result
|
||||
The resulting contraption is a modular, robust, lightweight and small package. I tested this out on my moderately dirtied bike, and the only issue I noticed was that, obviously, I did not have a lot of water. In the end, I did have enough water, but not enough to have an entirely comfortable margin for error. This small sacrifice is well worth the the convenience the solution affords me. I am pleased with the results.
|
||||
|
||||
{{< image
|
||||
src="images/full-assembly.jpg"
|
||||
caption="Keg pump sprayer" >}}
|
||||
|
||||
{{< image
|
||||
src="images/full-assembly-air-pump.jpg"
|
||||
caption="Keg pump sprayer with pump attached" >}}
|
||||
|
||||
{{< image
|
||||
src="images/full-assembly-packed.jpg"
|
||||
caption="Keg pump sprayer ready to go" >}}
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,64 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2024-04-27T05:12:13Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "burrito"
|
||||
title = "🌯 Burritos in Bulk"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
## About this post
|
||||
This is shallow dive into the mass production of homemade burritos. This recipe is designed to achieve a pareto-optimal mix of taste, simplicity, and cost-efficiency. The ingredients are few, the measurements exact and scalable.
|
||||
|
||||
## Not Reinventing the Wheel
|
||||
I used Taco Bell's ingredient labels and nutrition information to derive my own bean, rice, and beef recipe, mostly because it is fun to reverse-engineer foods that taste good. Taco Bell's success and veritable midwestern monopoly on Mexican-style fast-food is evidence enough of them having found their own kind of optimum. Yes, they have far more considerations than I do – supply chains / logistics, storage, shelf life, labor efficiency, ingredient availability, and profitability – so I may have reverse-engineered this burrito that exists in its particular form for reasons beyond my comprehension or utility, and in a manner much like a cargo-cult, but I have nothing better to aim at as I love their burritos.
|
||||
|
||||
|
||||
## Burrito Components - The Holy Quintet
|
||||
Just five entities compose the divine essence of **the burrito**.
|
||||
|
||||
### Tortilla
|
||||
I use a supermarket ~10" burrito-style flour tortilla. At this time I have no desire to even attempt making 35 burrito tortillas.
|
||||
|
||||
**UPDATE**: I made some excellent very large Chipotle-like burritos and they are very good.
|
||||
|
||||
### Beans
|
||||
The beans that result of the recipe are semi-solid, or, the consistency of something like a firm yogurt when at refrigeration temperatures. I tried two methods of applying the beans to the burrito. I tried to extrude the beans out of a pastry bag with a roughly 0.5" nozzle. This worked alright, but refilling the bag was a bigger mess than I was attempting to avoid by scooping, which brings me to the second method. I had a gigantic bowl of beans and scooped them onto the burrito. They are pinto beans with some salt, lard, and some seasonings.
|
||||
|
||||
### Beef
|
||||
I use the cheapest ground beef I can find, on the as-yet unproven but theoretically trivially easy to prove assumption that it produces the most lean meat per unit cost. In the mean time I reserve the belief that it was worth it in any case for the tallow I collect, a belief which I will deploy as a sort of coping mechanism if I find out I have gotten unlucky with my unusually lazy reasoning. It is just for fun.
|
||||
|
||||
**UPDATE**: I now have more beef fat and money saved than I know what to do with.
|
||||
|
||||
### Rice
|
||||
I use a rice cooker to cook long-grain white rice. I am not sure of the variance in recommended ratios of rice and water between rice cooker models, but after the cooking process is finished, all should result in cooked rice with similar ratios. Thus, substituting my own water weight measurement for any other should result in similar rice to this. This is something I have not put much thought into – ultimately almost certainly unimportant.
|
||||
|
||||
### Cheese
|
||||
I take a block of sharp cheddar cheese, divide it into sticks that can fit into the port on my food processor, and shred it into heaps and mounds. There is no recipe!
|
||||
|
||||
|
||||
## Assembly
|
||||
Each ~10" tortilla can accommodate at most ~8 oz. of filling. I generally aim to fill each with the following amounts:
|
||||
|
||||
| Ingredient | Weight (oz.) |
|
||||
| ---------- | ------------ |
|
||||
| beans | 2.25 |
|
||||
| beef | 2.25 |
|
||||
| rice | 2.25 |
|
||||
| cheese | 1.0 |
|
||||
|
||||
## Final Product
|
||||
|
||||
{{< image
|
||||
src="images/burritos.jpg"
|
||||
caption="Burrito Stack, do not bother counting, there are more out of frame" >}}
|
||||
|
||||
Reheat from frozen takes ~45" @ 450 F convection. Wrapped tightly in aluminum foil
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
+++
|
||||
categories = ["vehicle"]
|
||||
date = 2024-10-01T02:49:01Z
|
||||
description = ""
|
||||
draft = true
|
||||
slug = "brake-pad-replacement"
|
||||
title = "🔧 Brake Pad + Rotor Replacement"
|
||||
|
||||
+++
|
||||
|
||||
This task is not urgent and will not be complete any time soon. This log entry will take shape progressively as I ramp up to actually doing the thing. In my last post relating to car maintenance I mention the gruesome prospect of having my skull crushed between a thick slab of concrete and thousands of pounds of steel after the pinch welds of a twenty year old car give way. In this post I will take pains to avoid this. This time I cannot use the sturdy stout plastic ramp, I must use the `skull-crusher 9000` model jack stands. The front wheels of the vehicle must be suspended in the air so that I can remove them to access the brake pads. The car will be on jack stands, and part of my body will technically be underneath the car, but dear reader be assured that I plan to avoid putting my head between the car and the concrete as much as possible. This is how I win.
|
||||
|
||||
|
||||
## Replacing Brake Pads
|
||||
|
||||
The process for removing and replacing the pads is the more straightforward of the two maintenance tasks, requiring a short list of supplies:
|
||||
|
||||
|
||||
* 3/8" drive 10mm hex socket
|
||||
* Gloves
|
||||
* Jack stand (2)
|
||||
* Trolley jack
|
||||
* Flat head screw driver
|
||||
|
||||
The process is simple and I have not bothered to outline it yet. That is all I know for now. Stay tuned.
|
||||
|
||||
|
||||
## Replacing Brake Rotors
|
||||
|
||||
The process for removing and replacing rotors is not straightforward! Stay tuned.
|
||||
@@ -0,0 +1,103 @@
|
||||
images:
|
||||
- src: "images/bailey-outside-train-station.JPG"
|
||||
alt: "bailey outside train station"
|
||||
caption: "Bailey outside train station"
|
||||
- src: "images/boat.JPG"
|
||||
alt: "boat"
|
||||
caption: "Boat"
|
||||
- src: "images/cat-bed.JPG"
|
||||
alt: "cat bed"
|
||||
caption: "Cat bed"
|
||||
- src: "images/dc-capitol-building-distant-i.JPG"
|
||||
alt: "dc capitol building distant i"
|
||||
caption: "DC Capitol Building Distant I"
|
||||
- src: "images/dc-capitol-building-distant-ii.JPG"
|
||||
alt: "dc capitol building distant ii"
|
||||
caption: "DC Capitol Building Distant II"
|
||||
- src: "images/dc-hotel-room.JPG"
|
||||
alt: "dc hotel room"
|
||||
caption: "DC Hotel Room"
|
||||
- src: "images/dc-national-mall.JPG"
|
||||
alt: "dc national mall"
|
||||
caption: "DC National Mall"
|
||||
- src: "images/francis-scott-key-bridge-distant.JPG"
|
||||
alt: "francis scott key bridge distant"
|
||||
caption: "Francis Scott Key Bridge Distant"
|
||||
- src: "images/francis-scott-key-bridge-two.JPG"
|
||||
alt: "francis scott key bridge two"
|
||||
caption: "Francis Scott Key Bridge Two"
|
||||
- src: "images/francis-scott-key-bridge.JPG"
|
||||
alt: "francis scott key bridge"
|
||||
caption: "Francis Scott Key Bridge"
|
||||
- src: "images/helicopter.JPG"
|
||||
alt: "helicopter"
|
||||
caption: "Helicopter"
|
||||
- src: "images/museum-natural-history.JPG"
|
||||
alt: "museum natural history"
|
||||
caption: "Museum Natural History"
|
||||
- src: "images/norman-kitchen.JPG"
|
||||
alt: "norman kitchen"
|
||||
caption: "Norman Kitchen"
|
||||
- src: "images/nyc-amtrak.JPG"
|
||||
alt: "nyc amtrak"
|
||||
caption: "NYC Amtrak"
|
||||
- src: "images/nyc-map.JPG"
|
||||
alt: "nyc map"
|
||||
caption: "NYC Map"
|
||||
- src: "images/nyc-mikayla-apt-kitchen.JPG"
|
||||
alt: "nyc mikayla apt kitchen"
|
||||
caption: "NYC Mikayla Apt Kitchen"
|
||||
- src: "images/nyc-mikayla-apt-window.JPG"
|
||||
alt: "nyc mikayla apt window"
|
||||
caption: "NYC Mikayla Apt Window"
|
||||
- src: "images/nyc-train.JPG"
|
||||
alt: "nyc train"
|
||||
caption: "NYC Train"
|
||||
- src: "images/obelisk.JPG"
|
||||
alt: "obelisk"
|
||||
caption: "Obelisk"
|
||||
- src: "images/outside-train-station-fire-trucks.JPG"
|
||||
alt: "outside train station fire trucks"
|
||||
caption: "Outside Train Station Fire Trucks"
|
||||
- src: "images/outside-train-station.JPG"
|
||||
alt: "outside train station"
|
||||
caption: "Outside Train Station"
|
||||
- src: "images/pigeon-closeup.JPG"
|
||||
alt: "pigeon closeup"
|
||||
caption: "Pigeon Closeup"
|
||||
- src: "images/police-boat.JPG"
|
||||
alt: "police boat"
|
||||
caption: "Police Boat"
|
||||
- src: "images/police.JPG"
|
||||
alt: "police"
|
||||
caption: "Police"
|
||||
- src: "images/saxaphone-man.JPG"
|
||||
alt: "saxophone man"
|
||||
caption: "Saxophone Man"
|
||||
- src: "images/seagull.JPG"
|
||||
alt: "seagull"
|
||||
caption: "Seagull"
|
||||
- src: "images/squirrel-eating-trash-II.JPG"
|
||||
alt: "squirrel eating trash ii"
|
||||
caption: "Squirrel Eating Trash II"
|
||||
- src: "images/squirrel-eating-trash.JPG"
|
||||
alt: "squirrel eating trash"
|
||||
caption: "Squirrel Eating Trash"
|
||||
- src: "images/train-station-christmas-tree-flags.JPG"
|
||||
alt: "train station christmas tree flags"
|
||||
caption: "Train Station Christmas Tree Flags"
|
||||
- src: "images/train-station-christmas-tree.JPG"
|
||||
alt: "train station christmas tree"
|
||||
caption: "Train Station Christmas Tree"
|
||||
- src: "images/very-long-escalator-dc.JPG"
|
||||
alt: "very long escalator dc"
|
||||
caption: "Very Long Escalator DC"
|
||||
- src: "images/willard-intercontinental.JPG"
|
||||
alt: "willard intercontinental"
|
||||
caption: "Willard Intercontinental"
|
||||
- src: "images/wwii-memorial-ii.JPG"
|
||||
alt: "wwii memorial ii"
|
||||
caption: "WWI Memorial II"
|
||||
- src: "images/wwii-memorial.JPG"
|
||||
alt: "wwii memorial"
|
||||
caption: "WWI Memorial"
|
||||
@@ -0,0 +1,16 @@
|
||||
+++
|
||||
categories = ["travel"]
|
||||
date = 2024-12-29T00:00:00Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "dc-nyc"
|
||||
title = "🏛️ Washington, D.C & 🗽New York City, NY"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
We went to Washington, D.C. for the [Bloomberg Public Health Summit](https://americanhealth.jhu.edu/2024-bloomberg-american-health-summit) Bailey was invited to attend. We also stopped by NYC.
|
||||
|
||||
|
||||
|
||||
## Photos
|
||||
{{< gallerynew name="dcnyc" >}}
|
||||
@@ -0,0 +1,22 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","dominos"]
|
||||
date = 2024-10-22T21:26:10Z
|
||||
description = ""
|
||||
draft = true
|
||||
slug = "dominos-garlic-oil-blend"
|
||||
title = "🧄 Domino's Garlic Oil Blend"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
{{< image
|
||||
src="images/crisco-ingredient-list.PNG"
|
||||
caption="Crisco ingredient list" >}}
|
||||
|
||||
{{< image
|
||||
src="images/dominos-garlic-oil-blend-ingredient-list.jpeg"
|
||||
caption="Domino's Garlic Oil Blend ingredient list" >}}
|
||||
|
||||
{{< image
|
||||
src="images/dominos-garlic-oil-blend-nutrition.jpeg"
|
||||
caption="Domin's Garlic Oil Blend nutrition" >}}
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3f83efa96e3aad29683f39696534e42371dccd87a4869baa57661b45ac1996d5
|
||||
size 90731
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8f7ed6e83768b19ea7bcf3c8118c8154a70b5afeb09766cfbf494697673d552d
|
||||
size 281554
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d7ac2a3b41a77cb74f848468aeae445c05afc30814d31af15c2c9d51ff0b95c7
|
||||
size 22809
|
||||
@@ -0,0 +1,71 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["dns"]
|
||||
date = 2025-02-16T00:00:00Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "dynamic-dns"
|
||||
title = "🌐 Dynamic DNS"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I have a public IP assigned by my ISP, but it is not *static*, and is therefore subject to change at their whim. This is no good, since I like to be able to access my devices from beyond my local network, and any change in my public IP address will result in misconfigured DNS. A disaster. I need a way to be sure that my any change in my public IP address will trigger an automatic reconfiguration of certain DNS records. I am using AWS Route 53 for my DNS provider, and luckily there is a handy resource available [`route53-dynamic-dns-with-lambda`](https://aws.amazon.com/blogs/startups/how-to-build-a-serverless-dynamic-dns-system-with-aws/) which perfectly suits my need for DDNS.
|
||||
|
||||
### Route 53 Dynamic DNS with Lambda
|
||||
Rather than rehash all of the steps covered in the documentation for this tool, I will simply cover the broad strokes of the process and configuration. The repository linked above basically comes with an installer that walks the user through a few steps to automatically configure the DDNS. The user is left with a simple script that will update the Route 53 DNS records any time the sender IP address changes. This is explained pictographically below.
|
||||
|
||||
{{< image
|
||||
src="images/architecture.png"
|
||||
caption="Route 53 Dynamic DNS with Lambda" >}}
|
||||
|
||||
#### AWS CloudShell
|
||||
{{< image
|
||||
src="images/aws-cloudshell.jpg"
|
||||
caption="AWS Cloudshell" >}}
|
||||
|
||||
The guide recommends deploying their Cloud Development Kit using AWS CloudShell session, so this is what I do. The process amounts to cloning the git repo `route53-dynamic-dns-with-lambda`, running the `newrecord.py` script contained therein, and following the directions in the CLI bootstrap wizard. The process is basically:
|
||||
- clone the repo
|
||||
- change directory to the repo
|
||||
- install the CDK
|
||||
- run `newrecord.py`
|
||||
|
||||
{{< image
|
||||
src="images/aws-cloudshell-newrecord.py.jpg"
|
||||
caption="newrecord.py" >}}
|
||||
|
||||
#### Generate Configuration File
|
||||
The last step of the install wizard isa prompt appears to confirm all of the settings.
|
||||
|
||||
```bash
|
||||
##############################################
|
||||
# #
|
||||
# The following configuration will be saved: #
|
||||
# #
|
||||
Host name: www.uuard.com
|
||||
Hosted zone id: ZYZ12345678901234
|
||||
Record set TTL: 60
|
||||
Secret: SHARED_SECRET_123
|
||||
# #
|
||||
# do you want to continue? (y/n) #
|
||||
# #
|
||||
##############################################
|
||||
```
|
||||
|
||||
|
||||
And finally the following message is produced, along with a `dyndns.sh` script.
|
||||
> This can be run via a CRON or SystemD timer to periodically update your hostname.
|
||||
|
||||
```
|
||||
#####################################################
|
||||
# #
|
||||
# The Serverless Dynamic DNS solution is now ready. #
|
||||
# #
|
||||
#####################################################
|
||||
|
||||
www.uuard.com can be updated with the following command:
|
||||
./dyndns.sh -m set -u https://xyz1234567890xyz.lambda-url.eu-west-1.on.aws/ -h www.uuard.com -s SHARED_SECRET_123
|
||||
```
|
||||
|
||||
The only thing left to be done is to move the provided `dyndns.sh` script to my server and set up a `cron` job. I chose to run my script each week “At 00:00 on Sunday” (`0 0 * * 0`).
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,34 @@
|
||||
images:
|
||||
- src: "images/cake-piped-and-filled.JPG"
|
||||
alt: "cake piped and filled"
|
||||
caption: "Cake piped and filled"
|
||||
- src: "images/cake-slices.JPG"
|
||||
alt: "cake slices"
|
||||
caption: "Cake slices"
|
||||
- src: "images/cake-tier-two.JPG"
|
||||
alt: "cake tier two"
|
||||
caption: "Cake Tier Two"
|
||||
- src: "images/cake-topped.JPG"
|
||||
alt: "cake topped"
|
||||
caption: "Cake Topped"
|
||||
- src: "images/cakes-baked-removing.JPG"
|
||||
alt: "cakes baking removing"
|
||||
caption: "Cakes Baked Removing"
|
||||
- src: "images/cakes-baking.JPG"
|
||||
alt: "cakes baking"
|
||||
caption: "Cakes Baking"
|
||||
- src: "images/cakes-leveled.JPG"
|
||||
alt: "cakes leveled"
|
||||
caption: "Cakes Leveled"
|
||||
- src: "images/earl-grey-syrup.JPG"
|
||||
alt: "earl grey syrup"
|
||||
caption: "Earl Grey Syrup"
|
||||
- src: "images/mixed-berry-filling.JPG"
|
||||
alt: "mixed berry filling"
|
||||
caption: "Mixed Berry Filling"
|
||||
- src: "images/mixing-bowl-spatula.JPG"
|
||||
alt: "mixing bowl spatula"
|
||||
caption: "Mixing Bowl Spatula"
|
||||
- src: "images/powdered-sugar-sift.JPG"
|
||||
alt: "powdered sugar sift"
|
||||
caption: "Powdered Sugar Sift"
|
||||
@@ -0,0 +1,14 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2025-01-05T00:00:00Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "earl-grey-cake"
|
||||
title = "👑 Earl Grey Cake with Mixed Berry Filling"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Bailey made a cake, and I took some photos since her creations are always very beautiful. Nothing else going on here!
|
||||
|
||||
## Photos
|
||||
{{< gallerynew name="earl-grey-gallery" >}}
|
||||
@@ -0,0 +1,97 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2025-02-28T10:46:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "egg-replacer"
|
||||
title = "🥚 Egg Replacer"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Eggs are ~ $0.50 / egg, which is far too high a price to pay. This presents a fun opportunity to attempt a recipe using a custom formulation of egg replacer. The purpose of an egg can be many: to provide structure, leavening, moisture, and richness (fat). My goal is to formulate a substitute that, at least on paper, will replace the function of an egg using ingredients that are cheaper than an egg.
|
||||
|
||||
## Commercial Egg Replacers
|
||||
I chose the first two commercial egg replacers with easily-discoverable nutrients and ingredients that I found for my quick analysis.
|
||||
### ingredients
|
||||
The ingredient lists below show similarities between two different manufacturers' egg replacer formulations. Both contain leavening agents and thickening starches.
|
||||
|
||||
{{< image
|
||||
src="images/ingredients_egg-replacer_franklin-farms-east.PNG"
|
||||
caption="Franklin Farms East" >}}
|
||||
|
||||
{{< image
|
||||
src="images/ingredients_egg-replacer_bobs-red-mill.PNG"
|
||||
caption="Bob's Red Mill" >}}
|
||||
|
||||
### Instructions
|
||||
Their instructions for use are also the same. This reassures me that egg replacement is a solved problem, as it is widely commercially available and thus viable to its consumers.
|
||||
|
||||
{{< image
|
||||
src="images/instructions_egg-replacer_bobs-red-mill.PNG"
|
||||
caption="Bob's Red Mill Instructions" >}}
|
||||
|
||||
## Comparison to an actual egg
|
||||
According to the instructions provided by Bob's Red Mill and Franklin Farms East, one egg can be replaced with 1 Tbsp. `Egg Replacer` combined with 2 Tbsp. water. I will convert the volume measurements to weight measurements so that I can more easily compare them to an actual egg. Since 1 Tbsp. water weighs 15 grams, and one Tbsp. egg replacer weighs ~ 10 grams, the total weight or both egg replacement formulas is 45 grams.
|
||||
|
||||
| **Product** | **Weight (g)** | **Water (%)** |
|
||||
| ------------------------------------ | -------------- | ------------- |
|
||||
| **Eggs, Grade A, Large, egg whole** | 50 | 76 |
|
||||
| **Bob's Red Mill Egg Replacer** | 45 | 67 |
|
||||
| **Franklin Farms East Egg Replacer** | 45 | 67 |
|
||||
|
||||
|
||||
## Egg nutrients per 100 g
|
||||
|
||||
| **Nutrient** | **Average Amount** | **Unit** |
|
||||
| --------------------------- | ------------------ | -------- |
|
||||
| Water | 75.8 | g |
|
||||
| Energy | 147 | kcal |
|
||||
| Protein | 12.4 | g |
|
||||
| Total lipid (fat) | 9.96 | g |
|
||||
| Carbohydrate, by difference | 0.96 | g |
|
||||
| Carbohydrate, by summation | 0.2 | g |
|
||||
| Fiber, total dietary | <0.75 | g |
|
||||
| Sugars, Total | 0.2 | g |
|
||||
|
||||
## Adding fat (lecithin)
|
||||
Commercial egg replacers often exclude fat. I do not know the reasons for this exclusion, since eggs contain around 10% fat by weight. Maybe for shelf-stability, cost reduction, maximizing versatility. Fat has a shorter shelf-life, and is relatively expensive compared to starches and gums. Egg replacers often require processing the product in the end anyway: one must add water, mix, wait, etc. If adding fat is important, the user will add it themselves. Perhaps some of this reasoning is correct, I do not know. I intend to add some fat.
|
||||
|
||||
I will add fat in part through the addition of liquid sunflower lecithin. This sort of kills two birds with one stone: I will replace some of the missing fat (lecithin is made of phospholipids, a type of fat), and introduce the absent emulsifier (eggs are known to be great natural emulsifiers because their yolk contains lecithin). Since I am missing around 5 grams of weight between the water and fat, I will add around 1 gram of soy lecithin.
|
||||
|
||||
## Adding leavening (baking powder)
|
||||
I want to use baking powder in my formulation. This is what Franklin Farms East uses, and if it is good enough for them, it ought to be good enough for me. Also, it is convenient that I can make an estimate of how much baking powder is present in the egg replacer by deriving the value from the amount of sodium present. I need to determine how much baking powder is in the 100 g egg replacer mixture, given that it contains **170 mg of sodium**.
|
||||
|
||||
Baking powder (sodium acid pyrophosphate, sodium bicarbonate, etc.) contains sodium, obviously. Without having to do any complicated chemistry algebra, I can go directly to the source, at the nutrition label of baking powder, to see how much sodium is present in a given quantity. This image is from Bob's Red Mill baking powder. It contains **16059.37 mg** per 100 g of baking soda, or, **161 mg / g**.
|
||||
|
||||
{{< image
|
||||
src="images/baking-powder-sodium_bobs-red-mill.PNG"
|
||||
caption="Baking Powder Sodium" >}}
|
||||
|
||||
### Calculating baking powder
|
||||
|
||||
Let **$x$** be the amount of baking powder needed (in grams):
|
||||
|
||||
$$
|
||||
170 \text{ mg} = x \times 161 \text{ mg/g}
|
||||
$$
|
||||
|
||||
$$
|
||||
x = \frac{170}{161} \approx 1.06 \text{ g}
|
||||
$$
|
||||
|
||||
To obtain **170 mg of sodium** in the final mixture, I need **1.06%** baking powder by weight.
|
||||
|
||||
## Formulation
|
||||
Thus I arrive at my final formula, ready to test in a cake recipe.
|
||||
|
||||
**Ingredient Percentage and Amounts**
|
||||
| ingredient | % | amount (g) |
|
||||
| ------------------------- | ------- | ---------- |
|
||||
| Water | 65.00% | 32.5 |
|
||||
| Tapioca Starch | 18.50% | 6.0125 |
|
||||
| Oil \ fat | 12.00% | 6.00% |
|
||||
| Liquid Sunflower Lecithin | 3.00% | 1.50 |
|
||||
| Baking Powder | 1.50% | 0.4875 |
|
||||
| **TOTAL** | 100.00% | 50 |
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,306 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["automation"]
|
||||
date = 2024-06-18T02:47:45Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "email-alerts"
|
||||
title = "📧 Email Alerts with Postfix & Amazon SES"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I have many jobs on a server that run on a schedule using `cron` jobs. I would like to be notified when these jobs succeed, and especially when they fail. I will send and receive these alerts through email.Since it is impractical to run one's own email server in 2024, I am using AWS SES (Amazon Web Services - Simple Email Service) to send the emails. I do not write anything about this here.
|
||||
|
||||
## Prerequisites
|
||||
There are a few parts to configure to get the machine working.
|
||||
- Email server
|
||||
- `postfix`
|
||||
- `mailutils`
|
||||
|
||||
## Postfix
|
||||
This may prompt configuration GUI (`dpkg-reconfigure postfix`) in the terminal. Select "No configuration".
|
||||
|
||||
### Create auth file
|
||||
An auth file is needed to store credentials before hashing.
|
||||
```bash
|
||||
sudo apt install postfix
|
||||
sudo nano /etc/postfix/aws_ses_sasl
|
||||
```
|
||||
|
||||
`SMTP_USERNAME` (SES Access Key for IAM User) and corresponding SMTP_PASSWORD, e.g.:
|
||||
|
||||
```
|
||||
[email-smtp.us-east-2.amazonaws.com]:587 VMHPJVEZMOQJLJMPCHX:WF62TVHpGhqObyn2RvCb92sVxTP5OiihL3IvRmivHRk4j
|
||||
```
|
||||
|
||||
Now, I use the postmap tool to generate a hashed database file that will eventually be used by Postfix to authenticate to the remote SMTP email server. It will output a .db file of the same name as the input file – in this case, `aws_ses_sasl.db`.
|
||||
|
||||
```shell
|
||||
sudo postmap hash:/etc/postfix/aws_ses_sasl
|
||||
```
|
||||
|
||||
### Create postfix configuration file
|
||||
The auth file created above is referenced in the Postfix configuration file `main.cf` that is created below.
|
||||
|
||||
```shell
|
||||
sudo nano /etc/postfix/main.cf
|
||||
```
|
||||
|
||||
The last line above references a hashed database file. This file allows me to map my local email addresses to other addresses.
|
||||
|
||||
```sh
|
||||
relayhost = [email-smtp.us-east-2.amazonaws.com]:587
|
||||
smtp_sasl_auth_enable = yes
|
||||
smtp_sasl_security_options = noanonymous
|
||||
smtp_sasl_password_maps = hash:/etc/postfix/aws_ses_sasl
|
||||
smtp_use_tls = yes
|
||||
smtp_tls_security_level = encrypt
|
||||
smtp_tls_note_starttls_offer = yes
|
||||
smtp_generic_maps = hash:/etc/postfix/generic
|
||||
```
|
||||
|
||||
### Alias Configuration
|
||||
Server-generated email are by default sent to email address `{user}@{hostname}.localdomain`. I need to change this by configuring an alias for users `root` and `nicholas` in `/etc/aliases` so that all emails will be forwarded to my personal email instead of just ending up unread in my syslog.
|
||||
|
||||
```shell
|
||||
sudo nano /etc/aliases
|
||||
```
|
||||
|
||||
```shell
|
||||
root: nicholas@email.com
|
||||
nicholas: nicholas@email.com
|
||||
```
|
||||
|
||||
### Create `generic` file
|
||||
This will configure the sender address. Emails sent from the system will appear to be sent from the emails named here.
|
||||
|
||||
```shell
|
||||
sudo nano /etc/postfix/generic
|
||||
```
|
||||
|
||||
#### `generic`
|
||||
|
||||
```
|
||||
root@server.localdomain example@example.com
|
||||
nicholas@server.localdomain example@example.com
|
||||
```
|
||||
|
||||
#### Create hash database file
|
||||
|
||||
```shell
|
||||
sudo postmap hash:/etc/postfix/generic
|
||||
```
|
||||
|
||||
### Restart Postfix
|
||||
|
||||
```shell
|
||||
sudo service postfix restart
|
||||
```
|
||||
|
||||
### Test Email
|
||||
I want to test whether I have configured the email system correctly. I will need `mailutils`.
|
||||
|
||||
```shell
|
||||
sudo apt install mailutils
|
||||
```
|
||||
|
||||
I can set some variables in the shell to reference in a command:
|
||||
```shell
|
||||
email_body="test email body"
|
||||
email_subject="test email subject"
|
||||
email_to="test@example.com"
|
||||
email_from="alerts@uuard.com"
|
||||
```
|
||||
|
||||
Now I attempt to send the email.
|
||||
|
||||
```shell
|
||||
echo "${email_body}" | mail -s "${email_subject}" -r "${email_from}" "${email_to}"
|
||||
```
|
||||
|
||||
I check the mail log to verify the email was sent.
|
||||
|
||||
```shell
|
||||
sudo cat /var/log/mail.log | grep to=
|
||||
2024-06-17T19:30:17.705051-05:00 nas postfix/smtp[3797056]: D9D331661A14: to=, relay=email-smtp.us-east-2.amazonaws.com[3.22.8.243]:587, delay=0.82, delays=0.01/0/0.56/0.25, dsn=2.0.0, status=sent (250 Ok 010f019028c06bff-df62265f-49b2-4920-b73a-8a7e4b362f2c-000000)
|
||||
```
|
||||
|
||||
Notice the `250 Ok`. This means the email was sent successfully. This is confirmed when I check my email inbox, which shows the email has been delivered:
|
||||
|
||||
{{< image
|
||||
src="images/email-success.png"
|
||||
caption="Test Email" >}}
|
||||
|
||||
## Shell Scripts
|
||||
As I mentioned before, the purpose of configuring the system to send mail is to alert me to the outcome of the execution of scheduled jobs. To do this, I will create shell scripts for each job, within which will contain logic to send custom emails containing information that might be interesting to me.
|
||||
|
||||
I have a file `test.txt` that I output using `cat` command. Both the command and the output will be captured by the script, and the script will use the `mailutils` package `mail` command to send this over email. Here is what happens when I run the command manually:
|
||||
|
||||
### Formatting emails
|
||||
The email I sent is not very pretty, and the lack of formatting will result in a nigh unreadable jumble of text if a job produces an error. I want to be able to quickly discern what and why a job failed, so I will format my emails.
|
||||
|
||||
The idea is to separate the command that resulted in exix code indicating error, from the error itself. To test this, I will use `cat` as the command, and the contents of a text file as the output (since `cat` will output the contents of the text file).
|
||||
|
||||
```shell
|
||||
cat test.txt
|
||||
testing
|
||||
testing...
|
||||
testing.......
|
||||
testing..........
|
||||
it works.
|
||||
```
|
||||
|
||||
If I configure my script correctly, this exact command and output should show up in the email in separate columns. First, I configure the `test.sh` script.
|
||||
|
||||
```shell
|
||||
cmd="cat test.txt"
|
||||
cmd_output=$($cmd)
|
||||
|
||||
email_subject="cron job - show file contents"
|
||||
email_to="test@email.com"
|
||||
email_from="alerts@uuard.com"
|
||||
|
||||
# check if the cat command succeeded
|
||||
if [ $? -ne 0 ]; then
|
||||
email_subject="error!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#create variable email_body
|
||||
read -r -d '' email_body << EOM
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
td, th {
|
||||
font-family: helvetica;
|
||||
color: #FBF7EF;
|
||||
text-align:justify;
|
||||
padding: 1em;
|
||||
vertical-align:center;
|
||||
}
|
||||
table {
|
||||
background: #2B303B;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<th>command</th>
|
||||
<td><pre>${cmd}</pre></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>output</th>
|
||||
<td><pre>${cmd_output}</pre></td>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
EOM
|
||||
|
||||
echo -e "${email_body}" | mail --content-type=text/html -s "${email_subject}" -r "${email_from}" "${email_to}"
|
||||
|
||||
```
|
||||
|
||||
{{< image
|
||||
src="images/email-formatted.png"
|
||||
caption="Email Formatted" >}}
|
||||
|
||||
|
||||
## Improvements
|
||||
### Templating
|
||||
It occurs to me that it will be tedious and costly to maintain these configs if I want to change things. I decided to lift out the HTML template into a separate file so that I need only edit a single file. Now I have a few files to manage, but fewer lines of duplication between all of the configurations.
|
||||
|
||||
#### Template Script
|
||||
|
||||
```bash {filename="template-html-email.sh"}
|
||||
template=$(cat <<'EOF'
|
||||
<html lang="en">
|
||||
<head>
|
||||
<style>
|
||||
td, th {
|
||||
font-family: helvetica;
|
||||
color: #FBF7EF;
|
||||
text-align:justify;
|
||||
padding: 1em;
|
||||
vertical-align:center;
|
||||
}
|
||||
table {
|
||||
background: #2B303B;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table>
|
||||
<tr>
|
||||
<th>command</th>
|
||||
<td><pre>${cmd}</pre></th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>output</th>
|
||||
<td><pre>${cmd_output}</pre></td>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
)
|
||||
```
|
||||
|
||||
#### Send Error Email Script
|
||||
|
||||
```bash {filename="send-error-email.sh"}
|
||||
#!/bin/bash
|
||||
source dry-run-config.sh
|
||||
|
||||
cmd="$1"
|
||||
cmd_output="$2"
|
||||
export cmd
|
||||
export cmd_output
|
||||
|
||||
email_body=$(echo "$template" | envsubst)
|
||||
|
||||
#echo "Command: $cmd"
|
||||
#echo "Command Output: $cmd_output"
|
||||
#echo "Email Body: $email_body"
|
||||
|
||||
printf "%s" "$email_body" | mail --content-type=text/html -s "${email_subject}" -r "${email_from}" "${email_to}"
|
||||
|
||||
```
|
||||
#### Rclone Dry Run Config
|
||||
|
||||
```bash {filename="dry-run-config.sh"}
|
||||
#!/bin/bash
|
||||
|
||||
send_error_email_script="send-error-email.sh"
|
||||
template_html_email_script="template-html-email.sh"
|
||||
email_subject="cron alert"
|
||||
email_to="nicolaspatrickward@gmail.com"
|
||||
email_from="alert@uuard.com"
|
||||
|
||||
source "$template_html_email_script"
|
||||
|
||||
```
|
||||
|
||||
#### Dry Run
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# dry run config -- shared variables
|
||||
source dry-run-config.sh
|
||||
|
||||
# dry-run command parameters
|
||||
src_dir="/source"
|
||||
dest_remote="dry-run" #this is a remote rclone dir
|
||||
dest_dir="/dest"
|
||||
|
||||
# save both the command as executed, and the command output. Pass along to email script for formatting
|
||||
cmd="docker exec -t rclone rclone copy -v $src_dir $dest_remote:$dest_dir --dry-run"
|
||||
cmd_output=$($cmd)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
$send_error_email_script "$cmd" "$cmd_output"
|
||||
fi
|
||||
|
||||
```
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,46 @@
|
||||
images:
|
||||
- src: "images/bird.JPG"
|
||||
alt: "bird"
|
||||
caption: "Bird"
|
||||
- src: "images/bridge.JPEG"
|
||||
alt: "bridge"
|
||||
caption: "Bridge"
|
||||
- src: "images/christmas-tree.JPEG"
|
||||
alt: "christmas tree"
|
||||
caption: "Christmas Tree"
|
||||
- src: "images/copeland-falls.JPG"
|
||||
alt: "copeland falls"
|
||||
caption: "Copeland Falls"
|
||||
- src: "images/frozen-lake.JPEG"
|
||||
alt: "frozen lake"
|
||||
caption: "Frozen Lake"
|
||||
- src: "images/ground-squirrel.JPG"
|
||||
alt: "ground squirrel"
|
||||
caption: "Ground Squirrel"
|
||||
- src: "images/hotel.JPEG"
|
||||
alt: "hotel"
|
||||
caption: "Hotel"
|
||||
- src: "images/mountains.JPG"
|
||||
alt: "mountains"
|
||||
caption: "Mountains"
|
||||
- src: "images/murphys-resort.JPG"
|
||||
alt: "murphys resort"
|
||||
caption: "Murphys Resort"
|
||||
- src: "images/selfie-frozen-lake.jpg"
|
||||
alt: "selfie frozen lake"
|
||||
caption: "Selfie Frozen Lake"
|
||||
- src: "images/the-stanley-hotel-front.JPEG"
|
||||
alt: "the stanley hotel front"
|
||||
caption: "The Stanley Hotel Front"
|
||||
- src: "images/trail-selfie.JPEG"
|
||||
alt: "trail selfie"
|
||||
caption: "Trail Selfie"
|
||||
- src: "images/trail-snowman.JPG"
|
||||
alt: "trail snowman"
|
||||
caption: "Trail Snowman"
|
||||
- src: "images/trail.JPG"
|
||||
alt: "trail"
|
||||
caption: "Trail"
|
||||
- src: "images/tree-squirrel.JPG"
|
||||
alt: "tree squirrel"
|
||||
caption: "Tree Squirrel"
|
||||
@@ -0,0 +1,22 @@
|
||||
+++
|
||||
categories = ["travel"]
|
||||
date = 2023-01-02T12:00:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "estes-park"
|
||||
title = "⛰️ Estes Park, CO"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
## Estes Park
|
||||
|
||||
{{< video
|
||||
src="videos/estes-park.webm"
|
||||
width="100%"
|
||||
autoplay="true"
|
||||
loop="true"
|
||||
>}}
|
||||
|
||||
## Photo Gallery
|
||||
|
||||
{{< gallerynew name="estes-park-gallery" >}}
|
||||
@@ -0,0 +1,207 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["mssql","c#"]
|
||||
date = 2024-11-04T05:22:54Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "fooddata-central-ingest"
|
||||
title = "📑 Extracting & Transforming USDA's FoodData Central Data"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
USDA's *FoodData Central* is a comprehensive database of detailed information on food. Each food item has a detailed breakdown of nutrients, including macronutrients (like carbohydrates, fats, and proteins), micro-nutrients (vitamins and minerals), and other components (like water, caffeine, and alcohol). The site offers a download of the datasets in JSON format. I will write a script to transform the data to comport with the purposes of my recipe management system `Formulation`
|
||||
|
||||
## Steps
|
||||
* Create new C# .NET Core Console Application
|
||||
* .NET Core 8
|
||||
* Establish connection to the database
|
||||
* I create a service connection, which is just a way to let Visual Studio automatically write the connection string to my database and store it in user secrets file.
|
||||
|
||||
```c#
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
var config = new ConfigurationBuilder().AddUserSecrets().Build();
|
||||
var connectionString = config.GetConnectionString("FormulationDBSA");
|
||||
```
|
||||
|
||||
* Model the data in the JSON file in C#
|
||||
* I need to match Model property names with JSON property names
|
||||
|
||||
```c#
|
||||
public class FoundationFoodData
|
||||
{
|
||||
[JsonPropertyName("FoundationFoods")]
|
||||
public List FoundationFoods { get; set; }
|
||||
}
|
||||
|
||||
public class FoodItem
|
||||
{
|
||||
[JsonPropertyName("fdcId")]
|
||||
public int FdcId { get; set; }
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonPropertyName("foodNutrients")]
|
||||
public List FoodNutrients { get; set; }
|
||||
}
|
||||
|
||||
public class FoodNutrient
|
||||
{
|
||||
|
||||
[JsonPropertyName("nutrient")]
|
||||
public Nutrient Nutrient { get; set; }
|
||||
|
||||
[JsonPropertyName("amount")]
|
||||
public double Amount { get; set; }
|
||||
}
|
||||
|
||||
public class Nutrient
|
||||
{
|
||||
[JsonPropertyName("number")]
|
||||
public string Number { get; set; }
|
||||
[JsonPropertyName("unitName")]
|
||||
public string UnitName { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
* I am interested in only a few of the data model properties:
|
||||
* `fdcid` - unique identifier, use this as primary key in the database
|
||||
* `description` - essentially the name of the food item
|
||||
* `foodNutrients` - array of nutrient objects
|
||||
* I build the data model to only include those properties I am interested in:
|
||||
|
||||
```c#
|
||||
public class Ingredient
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public List Nutrients { get; set; }
|
||||
}
|
||||
public class IngredientNutrient
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public double Amount { get; set; }
|
||||
public string Unit { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### Read the JSON file
|
||||
```c#
|
||||
var data = JsonSerializer.Deserialize(jsonData);
|
||||
```
|
||||
|
||||
#### Create Dictionary for nutrient filtering and renaming
|
||||
```c#
|
||||
Dictionary NutrientFilter = new Dictionary
|
||||
{
|
||||
{ "Sodium", 307 },
|
||||
{ "Sugar", 269 },
|
||||
{ "Water", 255 },
|
||||
{ "Fat", 204 },
|
||||
{ "Fiber", 291 },
|
||||
{ "Protein", 203 },
|
||||
{ "Carbohydrate", 205 },
|
||||
{ "Calories", 957 }
|
||||
};
|
||||
```
|
||||
|
||||
#### Transform the FoundationalFoods object
|
||||
```c#
|
||||
var ingredients = new List();
|
||||
foreach (var food in data.FoundationFoods)
|
||||
{
|
||||
ingredients.Add(new Ingredient
|
||||
{
|
||||
Id = food.FdcId,
|
||||
Name = food.Description,
|
||||
Nutrients = food.FoodNutrients
|
||||
.Where(nutrient => NutrientFilter.Any(n => n.Value.ToString() == nutrient.Nutrient.Number))
|
||||
.Select(nutrient => new IngredientNutrient
|
||||
{
|
||||
Name = NutrientFilter.FirstOrDefault(filter => filter.Value.ToString() == nutrient.Nutrient.Number).Key,
|
||||
Amount = nutrient.Amount,
|
||||
Unit = nutrient.Nutrient.UnitName,
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Results
|
||||
I print formatted data to the console.
|
||||
|
||||
* Write the data to SQL Database
|
||||
* The last step is to write the data to the database. This must wait until I sort out how to structure the database tables.
|
||||
* For now, I add the fdc_id and water columns to my existing ingredient table, to see if this works.
|
||||
* Once again, I use Add-Migration and Update-Database to automatically update my table schema.
|
||||
|
||||
```c#
|
||||
foreach (var food in data.FoundationFoods)
|
||||
{
|
||||
ingredients.Add(new Ingredient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
FdcId = food.FdcId,
|
||||
Name = food.Description,
|
||||
Sodium = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Sodium"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Sugar = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Sugar"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Water = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Water"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Fat = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Fat"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Fiber = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Fiber"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Protein = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Protein"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Carbohydrate = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Carbohydrate"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
Kilocalorie = food.FoodNutrients.Where(fn => fn.Nutrient.Number == NutrientFilter["Calories"].ToString()).Select(n => n.Amount).FirstOrDefault(),
|
||||
IsFormulation = false
|
||||
});
|
||||
}
|
||||
|
||||
DataTable ingredientData = new DataTable();
|
||||
|
||||
ingredientData.Columns.Add(new DataColumn("id", typeof(Guid)));
|
||||
ingredientData.Columns.Add(new DataColumn("name", typeof(string)));
|
||||
ingredientData.Columns.Add(new DataColumn("is_formulation", typeof(bool)));
|
||||
ingredientData.Columns.Add(new DataColumn("kilocalorie", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("protein", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("fat", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("carbohydrate", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("fiber", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("sugar", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("sodium", typeof(double)));
|
||||
ingredientData.Columns.Add(new DataColumn("fdc_id", typeof(int)));
|
||||
ingredientData.Columns.Add(new DataColumn("water", typeof(double)));
|
||||
|
||||
foreach (var ingredient in ingredients)
|
||||
{
|
||||
ingredientData.Rows.Add(
|
||||
ingredient.Id,
|
||||
ingredient.Name,
|
||||
ingredient.IsFormulation,
|
||||
ingredient.Kilocalorie,
|
||||
ingredient.Protein,
|
||||
ingredient.Fat,
|
||||
ingredient.Carbohydrate,
|
||||
ingredient.Fiber,
|
||||
ingredient.Sugar,
|
||||
ingredient.Sodium,
|
||||
ingredient.FdcId.HasValue ? ingredient.FdcId.Value : DBNull.Value,
|
||||
ingredient.Water
|
||||
);
|
||||
}
|
||||
|
||||
using (var connection = new SqlConnection(connectionString))
|
||||
{
|
||||
await connection.OpenAsync();
|
||||
using (SqlBulkCopy bulkCopy = new SqlBulkCopy(connection))
|
||||
{
|
||||
bulkCopy.DestinationTableName = "ingredient";
|
||||
await bulkCopy.WriteToServerAsync(ingredientData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
{{< image
|
||||
src="images/json_db_ingest.exe.png"
|
||||
caption="Ingest Console App" >}}
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,96 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["C#","MSSQL","blazor"]
|
||||
date = 2024-11-02T18:24:31Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "formulation-web-app-I"
|
||||
title = "🔒 Adding User Account support to Web App"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
All of the changes I make to the `formulation` repository are tracked in [my private git SCM, Gitea](https://git.uuard.com/nicholas/formulation). This log will mostly only ever reference those changes so that I do not repeat the code here, in keeping with the principle of **DRY**.
|
||||
|
||||
## Discussion
|
||||
I built a simple app to manage my recipes. As of writing this post, the app only has minimal functionality, but it does everything I wanted it to do when I started the project. Before I decide to add additional features, I reason it will be good to add user account support first, mostly because this feature will touch almost every aspect of the project. Every additional feature will be constrained by the user account feature – pages will need authentication, and running certain operations will need proper authorization, database changes, etc. It is best to get this out of the way first.
|
||||
|
||||
|
||||
## First Steps - ASP.NET Core Identity
|
||||
First I look for precedent in implementing user accounts into a Blazor Webassembly app. Happily I have found Microsoft's very own example here: https://github.com/dotnet/blazor-samples/tree/main/8.0/BlazorWebAssemblyStandaloneWithIdentity
|
||||
|
||||
This example uses `ASP.NET Core Identity`, an API that supports UI login functionality and manages users, passwords, profile data, roles, claims, tokens, email confirmation, and more. This system of authentication and authorization will slot into my own project with a few minimally tedious changes, which I do not outline here. Alternatively, I could use an external credential system like `OIDC` to allow users to authenticate using external credentials from Microsoft, Google, etc. I decided not to go this route because it is generally more complicated to implement, and I have no real need.
|
||||
|
||||
## The state of things
|
||||
After some time I arrive at working implementation of Identity:
|
||||
* An unauthenticated, non-authorized user visits the `home` page. This page is configured to only show content when the user has proper authorization.
|
||||
{{< image
|
||||
src="images/notauthorized.png"
|
||||
caption="Not Authorized" >}}
|
||||
|
||||
* User visits the `/register` page to create an account. The supplied credentials are stored in the database.
|
||||
{{< image
|
||||
src="images/register.png"
|
||||
caption="Register Page" >}}
|
||||
{{< image
|
||||
src="images/register_success.png"
|
||||
caption="Registration Successful" >}}
|
||||
|
||||
* User visits the `/login` page and enters previously created credentials to be matched against those stored in the database.
|
||||
{{< image
|
||||
src="images/login.png"
|
||||
caption="Login" >}}
|
||||
{{< image
|
||||
src="images/login-success.png"
|
||||
caption="Login Success" >}}
|
||||
|
||||
* User logs in successfully and is able to visit `/home` page and see the protected content.
|
||||
|
||||
{{< image
|
||||
src="images/authorized.png"
|
||||
caption="Authorized User" >}}
|
||||
|
||||
For now, that is all I need. I now have a functional and secure implementation of a user membership system that I can build around.
|
||||
|
||||
|
||||
## Entity Framework Code-First Approach
|
||||
The next step is to streamline the development process for the database. For this I will use the "code-first" approach to Entity Framework. This allows me to define my database schema in C# code instead of designing the database first. This is especially useful when the ultimate requirements of the database are not well understood, which is common during the early, rapidly iterative development process.
|
||||
|
||||
This approach frees me to focus on mostly just the code and leaves the tedious task of continuously dropping and modifying databases – rewriting the entire SQL query that generates the database, to the computer using EF Core Package Manager Console Tools. This is a command-line tool that is already built in to Visual Studio.
|
||||
|
||||
Here is the basic process:
|
||||
|
||||
* I decide I do not like something about a table in my database. Maybe the name violates my naming convention. I simply change the name in the code.
|
||||
* In the Package Manager Console in Visual Studio I create a migration named SatisfyNamingConvention. The tool will automatically generate a pathway to changing the database to match my EF Models.
|
||||
|
||||
`Add-Migration SatisfyNamingConvention`
|
||||
|
||||
* In the Package Manager Console, I can apply the proposed database changes.
|
||||
|
||||
`Update-Database SatisfyNamingConvention`
|
||||
|
||||
By default, `Microsoft.AspNetCore.Identity` maps its built-in `IdentityUser` to a database table `ASPNetUser`. All of the other built-in models that attend the Identity membership system have this same ASPNet prefix. I do not like the name and I prefer to use snake_case in my databases. I use the process I described above to change the default names of the tables and columns to snake case.
|
||||
|
||||
## Single user → multi-user regime
|
||||
The app was built with a single user in mind. Now I will begin to transition the app to support user-specific data. As mentioned above, this will change the way the data is structured.
|
||||
|
||||
First, I will want to define the new structure of the data. Under the single-user regime, the data was structured very simply. There were only two tables: `ingredient` and `ingredient_relationship`. The ingredient could either be a *formulation* with a parent relationship with many child ingredients, or the ingredient could be a base ingredient, with only parent relationships. Now, each `ingredient_relationship` will share a foreign key with a `user`.
|
||||
|
||||
## User Table
|
||||
Table schema will be defined mostly by Microsoft Identity API.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/formulation_database_diagram_Microsoft.Identity_transparent_bg.png"
|
||||
caption="User Table" >}}
|
||||
|
||||
## Further Plans
|
||||
* Instead of relying entirely on manually entered nutritional data, I will replicate the foods datasets from Food Data Central in my own database as a built-in source for base ingredients. I will call these template ingredients.
|
||||
* Users may use these ingredients to create user ingredients. These custom ingredients are not added to global catalog of template ingredients. I will need to decide whether to create a new table or add a flag or discriminator column for these custom ingredients.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/formulation_database_diagram_all.png"
|
||||
caption="The Whole Thang" >}}
|
||||
|
||||
Done.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["C#","MSSQL","blazor"]
|
||||
date = 2025-04-15T12:45:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "formulation-web-app-II"
|
||||
title = "🥣 Formulation - Nutrition Calculator"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
**View the git repository [here](https://git.uuard.com/nicholas/formulation)**
|
||||
|
||||
In a [previous post]({{< relref "posts/formulation-web-app-I">}}) I added multi-user support to a web app I built with Microsoft's Blazor framework in C#, which I named **Formulation**. In this post I review a fully-working version of the project.
|
||||
|
||||
{{< video
|
||||
src="videos/formulation-demo-3x.mp4"
|
||||
width="100%"
|
||||
autoplay=true
|
||||
loop=true
|
||||
>}}
|
||||
|
||||
### Problem
|
||||
The problem is in my previous approach to estimating nutrition, which was to prepare many units of a single type of food item with known nutrition, e.g. frozen burritos. I knew that if I ate one whole `<food>` I would have consumed `<nutrition>`. This works, but it is not flexible: I must eat the whole `<food>` item, and only eat `<food>` items, or else be pained to calculate things manually.
|
||||
|
||||
### Solution - Weight-based recipes
|
||||
The main idea of the app is to allow me to quickly **calculate** nutrition data. It is not enough that I should **estimate** using sloppy heuristics, or even calculate using mess-making **volumetric** measurements. I cannot do it anymore! I will not fill measuring spoons and cups, scoop out the contents, rinse, scoop again, always scooping, always leveling and scooping and scooping and converting. Accuracy relies on consistent scooping technique and density, both can be highly variable. Flour, for example, can vary from scoop to scoop by around **30%**! This is an enormous margin and pure madness in a world where highly sensitive and accurate scales exist. I do not have to live like this anymore, and it has been a long time since I have. I will never go back. All I need is a scale, *and to patiently convert volume-based recipes into weight-based recipes.*
|
||||
|
||||
#### Example
|
||||
Suppose I ate something like this:
|
||||
- 3/5ths of a banana
|
||||
- leftover taco meat
|
||||
- some refried beans
|
||||
- flour tortilla
|
||||
- a dollop of sour cream
|
||||
|
||||
To understand what exactly I am about to eat, I simply:
|
||||
- place item on the scale
|
||||
- enter the weight
|
||||
- tare, and
|
||||
- repeat for each ingredient.
|
||||
|
||||
Done. The only real work is in the data entry, but over time I will have built out a personal database of custom food items and their nutrition and there will be little work left to do. No scooping, no converting!
|
||||
|
||||
---
|
||||
|
||||
### Demo
|
||||
I show and describe the app here.
|
||||
|
||||
##### Sign in / Sign up
|
||||
This is where a user can enter the app. If there is an issue with the sign in or registration process, they will be shown in a little warning box.
|
||||
|
||||
{{< image
|
||||
src="images/sign-up-error.jpg"
|
||||
caption="Sign Up Validation Failed" >}}
|
||||
|
||||
---
|
||||
|
||||
##### Toast
|
||||
This demonstrates a dismissible toast notification service that shows certain messages from the server.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/toast.webp"
|
||||
caption="Toast Notification" >}}
|
||||
|
||||
---
|
||||
|
||||
##### Editor Area
|
||||
This section lists all of the user's ingredients and formulations. Contains a search and filter. Any ingredient can be edited or removed here, and new ingredients can be added to formulations.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/editor.jpg"
|
||||
caption="Editor Area" >}}
|
||||
|
||||
---
|
||||
|
||||
##### Ingredients and Formulations
|
||||
Ingredients are the most basic unit of a formulation. An **ingredient** has nutrition information that is entered by the user, not calculated. A **formulation** is just a collection of ingredients and other formulations, each of which constitute a proportion of the formulation.
|
||||
|
||||
##### Ingredients
|
||||
I can create a new ingredient and fill in the nutrition data.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/edit-ingredient.jpg"
|
||||
caption="Edit Ingredient" >}}
|
||||
|
||||
---
|
||||
|
||||
##### Formulations
|
||||
The nutrition is calculated based on the nutrition present in the sub-ingredients, and according to the sub-ingredient proportion. In this example, I have created a formulation for *chocolate chip cookie dough*
|
||||
|
||||
{{< image
|
||||
src="images/edit-formulation.jpg"
|
||||
caption="Edit Formulation" >}}
|
||||
|
||||
---
|
||||
|
||||
##### Kitchen Area
|
||||
The kitchen area is where one can, for example, find out how much protein is in a preparation of food. For example, earlier I created a *chocolate chip cookie dough* formulation. Now I want to bake a cookie with 20 g of dough. I add the ingredient to the *quantity adjustment* area, and enter the amount `20`. The *results* area will display the nutrition for whatever ingredients appear in the *quantity adjustment* area, according to the amount entered.
|
||||
|
||||
{{< image
|
||||
src="images/kitchen.jpg"
|
||||
caption="Kitchen area" >}}
|
||||
|
||||
---
|
||||
|
||||
That is pretty much all. As with any software project, there are endless things still to be improved, corrected, or added, but I accomplished what I wanted with this project so I am allowing myself to call it "finished".
|
||||
@@ -0,0 +1,558 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","git","devops","kubernetes"]
|
||||
date = 2025-05-08T12:50:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "gitea-actions-kubernetes"
|
||||
title = "▶️ Gitea Actions & Kubernetes"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I currently use **Jenkins** to automate the build and deployment of my [personal website](https://nicholas.uuard.com) and my [blog site](https://log.nicholas.uuard.com). I will experiment with a **Gitea Actions** workflow to see if I can accomplish the same thing in Gitea.
|
||||
|
||||
This process should be familiar to me since the workflow I configure in this post will more or less mirror the behavior of my Jenkins setup: an independent service (e.g., Jenkins agent, Gitea runner) executes steps defined in a repo configuration file (e.g., `Jenkinsfile`, `demo.yml`).
|
||||
|
||||
> I wrote a post for each of my other Jenkins pipelines.
|
||||
> - [🚀 Hugo Site Deployment]({{< relref "posts/jenkins-deploy-web-log">}})
|
||||
> - [🤵🏻 Personal Website Deployment]({{< relref "posts/jenkins-deploy-website">}})
|
||||
|
||||
## Generate Registration token for runner
|
||||
I will first need to generate a **authentication / identification** token to register with the Gitea server. This is very simple using the web app GUI. I click the button **Create new Runner** and it generates the registration token for me. I will use this token later.
|
||||
|
||||
{{< image
|
||||
src="images/runnner-registration-token.png"
|
||||
caption="Generate registration token for gitea runner" >}}
|
||||
|
||||
I *could* have generated this using `gitea actions generate-runner-token` command inside the container, which would give me more control over the scope of the runner (e.g. global, org, repo scope) but I do not care such minutiae right now.
|
||||
|
||||
|
||||
## Create Gitea runner container
|
||||
Next I need to configure and run the **runner container**. To do this I will:
|
||||
- modify `docker-compose.yml` to include `gitea/act_runner`
|
||||
- add environment variables for `REGISTRATION_TOKEN`, etc.
|
||||
- restart the services.
|
||||
|
||||
#### act_runner
|
||||
Gitea provides an official image `gitea/act_runner`. For testing purposes, I will not need anything more than this and the basic ubuntu-based image `ubuntu-latest`.
|
||||
|
||||
This runner will be capable of running GitHub Actions-like workflows in Gitea -- it even uses the same [syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions) as GitHub Actions. Later, I will configure a workflow using this syntax that defines the "actions" the runner will perform on my repository.
|
||||
|
||||
Next I add a `runner` service to my `docker-compose.yml` file. Before, there were only 2 containers in the application services: `db` and `gitea`. I have added a third: `gitea_runner`:
|
||||
|
||||
```yaml
|
||||
networks:
|
||||
gitea:
|
||||
external: false
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:latest
|
||||
container_name: gitea
|
||||
environment:
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
- GITEA__database__DB_TYPE=mysql
|
||||
- GITEA__database__HOST=db:3306
|
||||
- GITEA__database__NAME=gitea
|
||||
- GITEA__database__USER=gitea
|
||||
- GITEA__database__PASSWD=${MYSQL_PASSWORD}
|
||||
restart: always
|
||||
networks:
|
||||
- gitea
|
||||
volumes:
|
||||
- ./data:/data
|
||||
#- ./logs:/var/lib/gitea/log
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "${GITEA_PORT}:3000"
|
||||
depends_on:
|
||||
- db
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:3000/api/healthz" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
db:
|
||||
image: mysql:8
|
||||
container_name: gitea_db
|
||||
restart: always
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||
- MYSQL_USER=gitea
|
||||
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
|
||||
- MYSQL_DATABASE=gitea
|
||||
networks:
|
||||
- gitea
|
||||
volumes:
|
||||
- ./mysql:/var/lib/mysql
|
||||
|
||||
runner:
|
||||
image: gitea/act_runner
|
||||
container_name: gitea_runner
|
||||
restart: always
|
||||
depends_on:
|
||||
gitea:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
volumes:
|
||||
- ./data/act_runner:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- GITEA_INSTANCE_URL=${GITEA_INSTANCE_URL}
|
||||
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
|
||||
- GITEA_RUNNER_NAME=${GITEA_RUNNER_NAME}
|
||||
|
||||
```
|
||||
|
||||
## Configure a demo workflow
|
||||
Now I need to configure a test workflow using the GitHub Actions syntax I mention above. Gitea provides a demo workflow that I will use to verify that I can successfully run a workflow in a runner container:
|
||||
|
||||
```yaml
|
||||
name: Gitea Actions Demo
|
||||
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Explore-Gitea-Actions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||
- name: List files in the repository
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
```
|
||||
|
||||
I place this `demo.yml` file in my `proving-ground` repository:
|
||||
```plaintext
|
||||
proving-ground/
|
||||
├── 📂.gitea/
|
||||
│ └── 📂workflows/
|
||||
│ └── demo.yml
|
||||
```
|
||||
|
||||
Before I push this workflow to the repository, I need to finish setting up the runner. Then, once I push the new code, Gitea Actions should trigger the workflow, according to the line `on: [push]`
|
||||
|
||||
|
||||
## Runner test
|
||||
I bring down the current Gitea application by running `docker compose down` and start it up again (this time including the new runner) with `docker compose up -d`. I navigate to the **Gitea Runners** in the web interface to verify that the runner is registered and running correctly:
|
||||
|
||||
{{< image
|
||||
src="images/gitea-runner.jpg"
|
||||
caption="Create runner" >}}
|
||||
|
||||
**✅It works**.
|
||||
|
||||
---
|
||||
|
||||
Now I can commit and push my new workflow `test.yml` and verify that the runner can perform Gitea Actions. After I push, I navigate to: **nicholas/proving-ground** → **Actions** to view the latest run:
|
||||
|
||||
{{< image
|
||||
src="images/test-workflow-running.jpg"
|
||||
caption="Test workflow - running" >}}
|
||||
|
||||
{{< image
|
||||
src="images/test-workflow-success.jpg"
|
||||
caption="Test workflow - success" >}}
|
||||
|
||||
**✅ It works**
|
||||
|
||||
---
|
||||
|
||||
The runner executed each step defined in the workflow configuration file.
|
||||
|
||||
Next I will configure a new workflow to perform a more useful automation.
|
||||
|
||||
## Build, Deploy
|
||||
The next step is to **build** and **deploy** to a web server. There are many ways to accomplish this depending on how I choose to architect my infrastructure. Since I am only deploying a static website and all work is being performed on the same machine, it would be simple to transfer the site files locally between host and containers. However, I will make things more interesting by artificially introducing *arguably* unnecessary complexity to the solution. This will move me closer to DevOps pipeline that would be seen in production.
|
||||
|
||||
|
||||
### Outline
|
||||
The pipeline will look something like this:
|
||||
|
||||
- Push changes to site repo, `master` branch
|
||||
- Trigger Action
|
||||
- Pull `master` branch
|
||||
- **build** a Docker image with new content
|
||||
- **push** Docker image to container registry
|
||||
- `k3s` deploys containerized web site
|
||||
|
||||
---
|
||||
|
||||
###### k3s / Kubernetes container
|
||||
When my action runner connects to the Kubernetes API at `https://server:6443` (as per `kubeconfig.yml`), it verifies the server's TLS certificate. If the hostname in the `kubeconfig.yml` ("server") does not match any SAN in the cert, the connection will fail with a TLS verification error. I can explicitly add "server" as a valid domain in the TLS cert to prevent this error.
|
||||
|
||||
`command: server --tls-san "server"`
|
||||
|
||||
```yaml
|
||||
k3s_server:
|
||||
image: "rancher/k3s:${K3S_VERSION:-latest}"
|
||||
container_name: k3s_server
|
||||
command: server --tls-san "server"
|
||||
tmpfs:
|
||||
- /run
|
||||
- /var/run
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
privileged: true
|
||||
restart: always
|
||||
environment:
|
||||
- K3S_TOKEN=${K3S_TOKEN:?err}
|
||||
#- K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yaml
|
||||
- K3S_KUBECONFIG_MODE=666
|
||||
networks:
|
||||
- cicd
|
||||
volumes:
|
||||
- k3s-server:/var/lib/rancher/k3s
|
||||
- ./k3s:/output
|
||||
ports:
|
||||
- 6443:6443 # Kubernetes API Server
|
||||
- ${INGRESS_CONTROLLER_PORT_HTTP}:80 # Ingress controller port 80
|
||||
- ${INGRESS_CONTROLLER_PORT_HTTPS}:443 # Ingress controller port 443
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Create static web page
|
||||
I create a sample static web page `index.html` that I will serve in my nginx container.
|
||||
|
||||
```plaintext
|
||||
📂site/
|
||||
├── index.html
|
||||
```
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hello, World!</title>
|
||||
<style>
|
||||
/*...*/
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Behold</h1>
|
||||
<p>The Cosmos is all that is or was or ever will be. Our feeblest contemplations of the Cosmos stir us — there is a tingling in the spine, a catch in the voice, a faint sensation, as if a distant memory, of falling from a height. We know we are approaching the greatest of mysteries. </p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
###### Create Dockerfile
|
||||
Next I will create the Dockerfile. This file will define a container that will serve the static web page `index.html` using nginx. The Dockerfile will remove all of the default files that ship with `nginx:alpine` and replace them with the contents of my `site` directory, which contains only `index.html`.
|
||||
|
||||
**`Dockerfile`**
|
||||
```Dockerfile
|
||||
FROM nginx:alpine
|
||||
RUN rm -rf /usr/share/nginx/html/*
|
||||
COPY ./site/ /usr/share/nginx/html/
|
||||
EXPOSE 80
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Set up credentials for image registry (gitea)
|
||||
One step in the workflow involves pushing the newly-built application Docker image to my registry. To push, I will need to authenticate with the Gitea server. To do this, I will use a GitHub action called `login-action@v3` found in the [GitHub Actions marketplace](https://github.com/marketplace?type=actions). This action requires a few inputs to work:
|
||||
- Registry URL
|
||||
- Username
|
||||
- Password (access token)
|
||||
|
||||
The registry URL and username inputs are straightforward and I can pass these variables along as strings. The access token will need to be passed as a secret. First I generate an access token in Gitea. I will name this `gitea-package-registry` and give it only the package write permission.
|
||||
|
||||
{{< image
|
||||
src="images/gitea-access-token-generate.png"
|
||||
caption="Generate registry token" >}}
|
||||
|
||||
Next I will store this as a secret so that I can securely pass it to the runner. I name the secret `REGISTRY_TOKEN`
|
||||
|
||||
{{< image
|
||||
src="images/gitea-access-token-secret.png"
|
||||
caption="Store registry token secret" >}}
|
||||
|
||||
{{< image
|
||||
src="images/registry-token-created.png"
|
||||
caption="Gitea registry token" >}}
|
||||
|
||||
I can reference this secret in my workflow file `${{ secrets.REGISTRY_TOKEN }}`.
|
||||
|
||||
---
|
||||
|
||||
###### Store kubeconfig as Gitea secret
|
||||
My workflow file needs access to one more secret: my `kubeconfig.yml`. Storing this as a secret is not as straightforward as the registry access token, since I cannot store a file directly as a secret. I will need encode the file as a base64 string, store *that* string as a secret named `KUBECONFIG_DATA`, and decode the string back into the `kubeconfig.yml` file.
|
||||
|
||||
First, my k3s container is configured to automatically produce a `kubeconfig.yml` and output it to the specified directory. I have included the relevant configuration from my `docker-compose.yml` file as context.
|
||||
|
||||
**`docker-compose.yml` snippet**
|
||||
```yaml
|
||||
environment:
|
||||
- K3S_KUBECONFIG_OUTPUT=/output/kubeconfig.yml
|
||||
volumes:
|
||||
- ./k3s:/output
|
||||
```
|
||||
|
||||
On my host machine, I can locate the generated `kubeconfig.yml` file at `./k3s/kubeconfig.yaml`. Next, I run the following command to base64-encode it and write the result to `kubeconfig.b64`.
|
||||
|
||||
```shell
|
||||
base64 -w 0 kubeconfig.yaml > kubeconfig.b64
|
||||
```
|
||||
|
||||
The contents of `kubeconfig.b64` is a string that will be stored as a secret in Gitea:
|
||||
|
||||
{{< image
|
||||
src="images/kubeconfig-data.png"
|
||||
caption="Base64 encoded secret" >}}
|
||||
|
||||
I will reference this secret in my **Gitea Action workflow**, which is defined in yet another yaml file-- this one stored in the same `workflows` directory as the `demo.yml` created in earlier step, during initial stages of setting up the pipeline.
|
||||
|
||||
```plaintext
|
||||
📂proving-ground/
|
||||
└─📂.gitea/
|
||||
└─📂workflows/
|
||||
├─demo.yml
|
||||
└─deploy.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Action Workflow file - deploy static web page
|
||||
The workflow file defines several steps that will compose the deployment process:
|
||||
- **⬇️Checkout** app repository (`proving-ground`)
|
||||
- **🔐Login** to Gitea package registry
|
||||
- **🐋Build** Docker image from Dockerfile
|
||||
- **🚀Push** new image to package registry
|
||||
- **🛠️Install** `kubectl`
|
||||
- **🔑Decode** `KUBECONFIG_DATA` secret and write local **`kubeconfig.yml`** file; Use this file as `KUBECONFIG` env.
|
||||
- **🚀Test / Deploy** to Kubernetes cluster via API
|
||||
- **🧹Prune** images
|
||||
|
||||
The `kubectl` CLI utility uses my `kubeconfig.yml` file to authenticate and connect to the Kubernetes cluster in the k3 container. It communicates with the Kubernetes API over HTTP/HTTPS (`https://server:6443`).
|
||||
|
||||
**deploy.yml**
|
||||
|
||||
```yaml
|
||||
name: Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-site:
|
||||
name: 'deploy'
|
||||
env:
|
||||
REGISTRY_USERNAME: nicholas
|
||||
IMAGE_REGISTRY: git.uuard.com
|
||||
IMAGE_NAME: nicholas/proving-ground
|
||||
IMAGE_TAG: latest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🔐 Gitea Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.IMAGE_REGISTRY }}
|
||||
username: ${{ env.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: 🐋 Build Docker image
|
||||
run: docker build -t $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG ./container
|
||||
|
||||
- name: 🚀 Push Docker image
|
||||
run: docker push $IMAGE_REGISTRY/$IMAGE_NAME:$IMAGE_TAG
|
||||
|
||||
- name: 🛠️ Setup kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
with:
|
||||
version: 'latest'
|
||||
|
||||
- name: 🔑 Configure Kubeconfig
|
||||
run: |
|
||||
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > kubeconfig.yml
|
||||
|
||||
- name: 🧪 Test access
|
||||
env:
|
||||
KUBECONFIG: kubeconfig.yml
|
||||
run: kubectl get nodes
|
||||
|
||||
- name: 🚀 Deploy to k3s
|
||||
env:
|
||||
KUBECONFIG: kubeconfig.yml
|
||||
run: |
|
||||
kubectl apply -f k8s/deployment.yml
|
||||
kubectl apply -f k8s/service.yml
|
||||
kubectl apply -f k8s/ingress.yml
|
||||
kubectl rollout restart deployment proving-ground
|
||||
kubectl rollout status deployment/proving-ground
|
||||
|
||||
- name: 🧹 Cleanup images
|
||||
if: always()
|
||||
run: docker image prune -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Diagram
|
||||
This diagram shows the relationship between the various containers.
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph host[Host]
|
||||
subgraph docker[Docker]
|
||||
subgraph kubernetes[Kubernetes / k3s]
|
||||
subgraph pod[Pod]
|
||||
container["NGINX"]
|
||||
end
|
||||
end
|
||||
gitea-server[Gitea Server]
|
||||
gitea-runner[Gitea Action Runner]
|
||||
|
||||
gitea-server <--> gitea-runner
|
||||
gitea-runner --Kubernetes API--> kubernetes
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
###### Kubernetes deployment manifest
|
||||
My Kubernetes manifest defines a basic static website deployment using the NGINX container image `proving-ground:latest`. The manifest configuration includes three resource types:
|
||||
- **Deployment** (`deployment.yml`)
|
||||
- `kubectl apply -f k8s/deployment.yml`
|
||||
- Runs the NGINX container inside a pod
|
||||
- **Service** (`service.yml`)
|
||||
- `kubectl apply -f k8s/service.yml`
|
||||
- Exposes the pod containing the NGINX app, makes it accessible inside the cluster
|
||||
- **Ingress** (`ingress.yml`)
|
||||
- `kubectl apply -f k8s/ingress.yml`
|
||||
- Configures routing rules to expose NGINX app externally
|
||||
|
||||
|
||||
**deployment.yaml**
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: proving-ground
|
||||
labels:
|
||||
app: proving-ground-static-site
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: proving-ground-static-site
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: proving-ground-static-site
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: git.uuard.com/nicholas/proving-ground:latest
|
||||
ports:
|
||||
- containerPort: 80
|
||||
```
|
||||
|
||||
**ingress.yml**
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: proving-ground-static-site-ingress
|
||||
spec:
|
||||
rules:
|
||||
- host: server
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: proving-ground-static-site-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
**service.yml**
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: proving-ground-static-site-service
|
||||
spec:
|
||||
selector:
|
||||
app: proving-ground-static-site
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 80
|
||||
type: LoadBalancer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Site repo directory structure
|
||||
This is the complete repository directory structure.
|
||||
```
|
||||
📁proving-ground
|
||||
├─📁.gitea
|
||||
│ └─📁workflows
|
||||
│ ├─demo.yml
|
||||
│ └─deploy.yml
|
||||
├─📁container
|
||||
│ ├─Dockerfile
|
||||
│ └─📁site
|
||||
│ └─index.html
|
||||
└─📁k8s
|
||||
├─deployment.yml
|
||||
├─ingress.yml
|
||||
└─service.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
###### Test Kubernetes API / kubectl
|
||||
Now I can test the Kubernetes connection by running `kubectl get nodes`. If this step succeeds, I have verified the connection between the runner container and Kubernetes. I can trigger this build by either pushing to `master` or manually triggering the build via Gitea UI.
|
||||
|
||||
{{< image
|
||||
src="images/k3s-test-success.png"
|
||||
caption="Deployment test success" >}}
|
||||
|
||||
**✅ It works**
|
||||
|
||||
---
|
||||
|
||||
###### Deploy
|
||||
Now I deploy the app. I simply push changes to the repository, and voilà, my app is live.
|
||||
|
||||
{{< image
|
||||
src="images/k3s-deploy-success.png"
|
||||
caption="Deployment success" >}}
|
||||
|
||||
{{< image
|
||||
src="images/view-site-deployment.png"
|
||||
caption="View deployment in browser" >}}
|
||||
|
||||
|
||||
**🎉 It works!**
|
||||
Done.
|
||||
@@ -0,0 +1,58 @@
|
||||
+++
|
||||
categories = ["build","plants"]
|
||||
date = 2025-01-01T16:30:00Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "grow-lights"
|
||||
title = "💡 Houseplant Grow Lights"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
It is winter and the days are short and dark. House plants grow better with more light. I will build two grow lights using off the shelf electric supplies.
|
||||
|
||||
{{< video autoplay=true loop=true
|
||||
src="videos/timelapse-plant-full.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
## Summary
|
||||
The plants sit on a wire shelf cart. Some are not looking so good. The 4 notched posts that support the shelves are hollow inside, with an inner diameter that is a bit more than the outer diameter of 1/2" EMT conduit. This is lucky, as it allows me to slot in a length of EMT conduit upon which I build the rest of the lamp. I am able to rotate the conduit inside the posts to change the direction of light, and the lamp holder adds an additional degree of freedom by allowing me to change the vertical tilt of the lamp. There is enough slop / allowance between the cart posts and the conduit that rotate easily, but not so much that the lamp is unstable. Thanks to electrical standards, all of the components I build off of the EMT conduit can be made compatible with each other. I plug an outlet strip into a remote switch my parents gave me for my birthday. The plants now reach directly upward instead of angling toward whichever direction the window happens to be that day (the cart moves a lot). The cart can still move around even tethered to a wall outlet. Mounting the lamps to the cart means no floor clutter to bump into. I am pleased with how the build process went, and the result.
|
||||
|
||||
{{< image
|
||||
src="images/plant-cart-lights-on.JPG"
|
||||
caption="Plant cart with lights ON" >}}
|
||||
|
||||
{{< image
|
||||
src="images/grow-light-close.JPG"
|
||||
caption="Light close up" >}}
|
||||
|
||||
## Parts List
|
||||
{{< image
|
||||
src="images/components.jpg"
|
||||
caption="Parts" >}}
|
||||
|
||||
### LED Bulb
|
||||
I chose a 5000K 25 W PAR38 bulb with 40 degree light cone.This gives great coverage and minimal wasted light. A second bulb was purchased on clearance at *Menards* for less than $5.00, a price which matches its performance. It gets terribly, hot, does not produce nearly as much light as the other at roughly the same power, and has a very wide angle light cone. I do not mind any of this for such a price, though. This is not a serious growing operation and frankly the plants get enough light to do just fine through the winter anyway. The color temperatures of the bulbs are matched to daylight \ full spectrum light. Nearly all of the houseplants that sit atop the cart benefit from bright indirect daylight, which is something like 10,000-20,000 lux. I use an app on my phone to measure the illuminance (lux), which I understand is not as precise as a proper light meter. Once again, this is good enough. Several apps converge on around the same "measurements" of illuminance, and many have confirmed that the apps get close to the values measured on light meters. Placed just at the tops of the plants, my phone produces a reading of ~15,000 lux. Great.
|
||||
|
||||
### EMT Conduit
|
||||
I bought two (2) 5' lengths of 1/2" EMT Conduit because 10' would not fit inside a small car. I wanted a little bit of extra height to take full advantage of the beam angle of the floodlight, but it turns out that the few inches of additional height would not nearly have been worth the trouble of hack sawing my way through the metal tubes.
|
||||
|
||||
### Wiring
|
||||
I had a couple of extension cables that I trimmed the female leads off. I stripped back ~6" of the outer sheathing and secured them into a cord grip connector that screws into the threaded hole on the electrical box that is fastened to the end of the conduit. I wired everything up, white to white, black to black. It is a bad idea to rely on color alone at such voltages, so I verify hot, neutral continuity across the plug conductor and the lamp socket conductor. Everything checks out, so I screw on the wire nuts.
|
||||
|
||||
### Final Assembly
|
||||
I slide the conduit in the cart post, close up the box by screwing on the face plate. I attach the box to the top of the conduit by sliding the set-screw connector over the conduit and tightening the screw. I plug it in, and _voila_.
|
||||
|
||||
## Thoughts
|
||||
The loop of flexible cord sprouting from the top of the box looks only a little bit silly. There is a cleaner way to do this involving more fittings and more time, but it is acceptable to me considering this is a seasonal cart adornment. I could route the wires internal to the conduit, attach a second box at the bottom, put a dimmer switch in it. Wire it to an IoT relay switch for automated control. Endless possibilities. Realistically I will do none of these things as there is almost no additional benefit to me or the plants, but it is satisfying to think how painless it would be to expand the system. I am happy that 2025 is going according to plan.
|
||||
|
||||
## Update - Added Grow Panel
|
||||
We bought a 2 ft. shop light from Harbor Freight Tools and zip-tied it to the underside of the top shelf. Now all of the plants on the cart get great light year-round.
|
||||
|
||||
{{< image
|
||||
src="images/grow-light-panel-light.JPG"
|
||||
caption="Plant light panel" >}}
|
||||
|
||||
{{< image
|
||||
src="images/grow-light-panel.JPG"
|
||||
caption="Plant light panel" >}}
|
||||
@@ -0,0 +1,124 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2025-02-22T11:51:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "hot-fudge"
|
||||
title = "🍦 Hot Fudge"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I want to make the the [NYTimes Hot Fudge Sauce](https://cooking.nytimes.com/recipes/6094-hot-fudge-sauce) recipe. The recipe provides mostly volume measurements, so I will have to convert these to weight values. Also as usual, I will stubbornly substitute some ingredients for more shelf-stable and/or cheaper options.
|
||||
|
||||
## NYTimes Recipe
|
||||
This is the list provided by the NYTimes recipe. Every single ingredient quantity except chocolate is provided by volume.
|
||||
|
||||
| quantity | ingredient |
|
||||
| -------- | --------------------------------------- |
|
||||
| 2 cups | heavy cream |
|
||||
| 4 tbsp | unsalted butter |
|
||||
| ½ cup | light brown sugar |
|
||||
| ¾ cup | granulated sugar |
|
||||
| ¼ tsp | fine sea salt |
|
||||
| 2 oz | bittersweet chocolate (in small pieces) |
|
||||
| 1¼ cups | sifted high-fat Dutch-process cocoa |
|
||||
| ½ tsp | vanilla extract |
|
||||
|
||||
## Substitutions
|
||||
### heavy cream → (water + oil + dry milk powder)
|
||||
The recipe requires 2 cups of heavy cream. The density of heavy cream is something like 1.01 to 1.03 g/mL, so 2 cups of heavy cream weighs approximately 484 grams. I will use this weight as the basis for calculating the amounts of each substitution ingredient. I simply multiply their respective proportions by the total weight. The table below is the resulting values:
|
||||
|
||||
**heavy cream nutrient breakdown**
|
||||
| nutrient | proportion | weight |
|
||||
| ------------- | ---------- | ------ |
|
||||
| water | 0.58 | 281 |
|
||||
| carbohydrates | 0.04 | 19 |
|
||||
| protein | 0.02 | 10 |
|
||||
| milk fat | 0.36 | 174 |
|
||||
|
||||
The water and fat substitutions are trivial: water is water, and milk fat is (as far as the substitution is concerned) fat, but I still need to account for 19 grams **carbohydrates** and 10 grams **protein**. Heavy cream is a dairy product, basically milk. So I will replace the carbs and protein that is missing as a result of its absence with non-fat milk powder. This product is basically heavy cream with the water and fat removed.
|
||||
|
||||
**non-fat milk powder nutrient breakdown**
|
||||
| nutrient | proportion | weight |
|
||||
| ------------- | ---------- | ------ |
|
||||
| carbohydrates | 0.52 | 15 |
|
||||
| protein | 0.35 | 10 |
|
||||
|
||||
Non-fat milk powder has approximately what I am missing: 15 grams of carbs and 10 grams of protein. Good enough.
|
||||
|
||||
**substitution breakdown**
|
||||
| nutrient | substitution | weight |
|
||||
| ------------- | -------------------- | ------ |
|
||||
| water | water | 281 |
|
||||
| carbohydrates | milk powder | 29 |
|
||||
| protein | milk powder | |
|
||||
| milk fat | vegetable shortening | 174 |
|
||||
|
||||
### unsalted butter → (water + oil)
|
||||
The recipe requires 4 tbsp. unsalted butter. This weighs 56 grams. Once again, however, I will substitute this relatively expensive ingredient with cheaper, more shelf stable ingredients: **oil** and **water**.
|
||||
|
||||
**unsalted butter nutrient breakdown**
|
||||
| nutrient | proportion | weight |
|
||||
| -------- | ---------- | ------ |
|
||||
| milk fat | 0.80 | 45 |
|
||||
| water | 0.2 | 11 |
|
||||
|
||||
The substitution is 1:1:
|
||||
|
||||
**substitution breakdown**
|
||||
| nutrient | substitution | weight |
|
||||
| -------- | ------------ | ------ |
|
||||
| milk fat | water | 45 |
|
||||
| water | water | 11 |
|
||||
|
||||
|
||||
### fine sea salt → table salt
|
||||
The recipe requires 1/4 tsp. fine sea salt. I will be substituting fine sea salt with table salt, which has a relative density of 0.90. According to the label on my table salt, ¼ tsp. weighs 1.5 grams. I will need 1.35 grams of table salt.
|
||||
|
||||
### vanilla extract → imitation vanilla
|
||||
They really nailed this substitution. Imitation is functionally identical to vanilla extract. I substitute it 1:1.
|
||||
|
||||
## Conversions
|
||||
I do not document every conversion here. Nutrition labels typically provide volume *and* weight values for a serving size. It is not very interesting.
|
||||
|
||||
### Dutch-process cocoa
|
||||
This one is tricky. The recipe requires 1 ¼ US cups high-fat Dutch-process cocoa, sifted _then_ measured. I have no interest in learning how they scoop and measure their volume of cocoa powder, so I will just defer to the numbers provided on the nutrition label and convert directly. According to this, 1 Tbsp. weighs 6 grams, and I need 1.25 cups.
|
||||
|
||||
*Tbsp in 1.25 cups*
|
||||
|
||||
$1.25 \times 16 = 20$
|
||||
|
||||
*grams of cocoa in 20 Tbsp.*
|
||||
|
||||
$20 \times 6 = 120$
|
||||
|
||||
**I will need 120 grams of Dutch process cocoa.**
|
||||
|
||||
### Vanilla Extract
|
||||
½ tsp. vanilla extract has a density of close enough to 1.0 g/mL. 1 tsp. is 5 mL.
|
||||
|
||||
**I will need 2.5 grams of vanilla extract.**
|
||||
|
||||
## Result
|
||||
This table is the result of the substitutions and conversions above.
|
||||
|
||||
| ingredient | weight |
|
||||
| ---------------------- | ------ |
|
||||
| water | 292 |
|
||||
| oil | 219 |
|
||||
| nonfat dry milk powder | 29 |
|
||||
| granulated sugar | 144 |
|
||||
| light brown sugar | 96 |
|
||||
| salt | 1.35 |
|
||||
| bittersweet chocolate | 57 |
|
||||
| dark cocoa powder | 120 |
|
||||
| vanilla extract | 2.5 |
|
||||
| _TOTAL_ | _961_ |
|
||||
|
||||
### Process
|
||||
Naturally I will rewrite the process to account for my substitutions:
|
||||
|
||||
1. In a saucepan, combine oil, sugars, water, and salt. Bring to a simmer over medium-low heat. Simmer 45 seconds. Add chocolate, and whisk to dissolve. Remove from heat, add cocoa, and whisk until no lumps remain.
|
||||
2. Return pan to low heat, and simmer sauce until glossy, whisking constantly, 20 seconds. Remove from heat, and stir in vanilla. Serve warm.
|
||||
|
||||
To reheat sauce, warm in a saucepan over low heat, stirring constantly. Do not boil.
|
||||
@@ -0,0 +1,245 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["hypervisor","virtualization"]
|
||||
date = 2025-06-27T19:00:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "infrastructure-overhaul"
|
||||
title = "🔨 Infrastructure Overhaul"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
This overhaul of my infrastructure is mostly about **separating concerns**. The two main concerns being separated are: **storage** and **services**. Mainly, I am moving all of the containers off my storage server and onto their own machine. They call this **storage-service decoupling**. There are also some improvements I will make along the way.
|
||||
|
||||
## ⚠️ Problems
|
||||
|
||||
There are a couple main problems with current configuration that I will need to address:
|
||||
- Storage-device coupling
|
||||
- Miscellaneous *infrastructure smell* / anti-patterns
|
||||
|
||||
**🔗 Storage-Service Decoupling**
|
||||
|
||||
I was running a lot of containers on my so-called "storage" server, and this was eating into resources originally meant for ZFS, mainly RAM. Every service I added was jeopardizing the performance and integrity of my most precious piece of infrastructure. There is no *inherent* problem with this configuration, and there are even good reasons to have services local to the storage. After all, ZFS managed to carry on just fine for years in spite of being somewhat RAM-starved. However, I wanted to move services off my storage server so it could be free to simply **store** things in the way ZFS was designed. It seems the proper way.
|
||||
|
||||
**💩 Miscellaneous Infrastructure Anti-Patterns**
|
||||
|
||||
The overhaul of my infrastructure is also meant to remove a general sense that things were *wrong*. I was running Docker Desktop in Windows and Docker in Linux, across two different machines. Worse still, I was running a Kubernetes swarm inside a Docker container on a NAS server. Resources were spread across multiple machines and containers. Things like this, they smell. It is not *necessarily* wrong, but it *smells* wrong and I do not like it.
|
||||
|
||||
## ✅ Solutions
|
||||
I go through the general solution to each of the problems presented above.
|
||||
|
||||
### 1️⃣ MACHINE 1 (NAS Server)
|
||||
|
||||
**Addressing storage-service coupling, anti-patterns**
|
||||
|
||||
**(Debian → TrueNAS SCALE)**
|
||||
|
||||
Since the storage server is no longer general purpose compute and storage, which requires the flexibility of a good general purpose OS (Debian 12), but instead **a dedicated NAS server**, I replace the OS with a dedicated NAS OS (TrueNAS SCALE) to make storage server management easier. TrueNAS SCALE comes with a all of the management tools/features I need.
|
||||
|
||||
I move the docker services off the Debian host and onto a VM on another machine.
|
||||
|
||||
**NAS Configuration Diagram**
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph host2["NAS (TrueNAS SCALE)"]
|
||||
subgraph zpool[ZFS Pool]
|
||||
subgraph nfs[NFS Dataset]
|
||||
nfsshare[NFS Shares]
|
||||
end
|
||||
subgraph smb[SMB Dataset]
|
||||
smbshare[SMB Shares]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
---
|
||||
|
||||
#### 🔂 Process
|
||||
The process of migrating from Debian to TrueNAS SCALE is straightforward, mostly thanks to highly portable nature of ZFS pools and my decision to store almost everything important there. I do not need to move any Docker-related files since they are stored on the ZFS pool. I can painlessly wipe the OS disk without worrying about destroying anything important. This is the way it should be.
|
||||
|
||||
The rest of the process involves configuration in TrueNAS SCALE, including things like configuring datasets, shares, users, email alerts, etc.
|
||||
|
||||
- Make bootable USB with [Etcher tool](https://etcher.balena.io/)
|
||||
- Make backups of important OS disk configuration files, etc.
|
||||
- cron, etc.
|
||||
- Export ZFS pool
|
||||
- `sudo zpool export data-pool`
|
||||
- Boot into TrueNAS SCALE installer environment and install OS
|
||||
- Import ZFS pool in TrueNas SCALE web GUI
|
||||
|
||||
✅ Done. Basic storage functionality is now restored to the NAS server.
|
||||
|
||||
{{< image
|
||||
src="images/truenas-scale-memory-usage.PNG"
|
||||
caption="TrueNAS SCALE using every bit of RAM it can" >}}
|
||||
|
||||
{{< image
|
||||
src="images/truenas-scale-storage.PNG"
|
||||
caption="TrueNAS SCALE storage information" >}}
|
||||
|
||||
---
|
||||
|
||||
#### 💾 ZFS, Datasets Configurations
|
||||
|
||||
Next I need to complete some configuration to restore complete functionality to my service and network storage.
|
||||
|
||||
- **Creating ZFS datasets for each service**
|
||||
- ✅ Separation of concerns -- isolates data per service. Makes resources more manageable, secure, fault tolerant
|
||||
- ✅ Granular control per dataset (compression, quotas, ACLs)
|
||||
- ✅ Snapshot/replication: Enables service-specific backups
|
||||
- **Create, configure POSIX service users, groups**
|
||||
- **Configure POSIX permissions, ACLs for users**
|
||||
- ✅ Principle of least privilege
|
||||
- ✅ ACLs allow flexible permit
|
||||
- **Share datasets**
|
||||
- NFS shares (**Linux** clients):
|
||||
- Kubernetes PVs
|
||||
- SMB shares (**Windows** clients):
|
||||
- Nicholas
|
||||
- User 2
|
||||
- ...
|
||||
- ✅ Dedicated storage per workload: benefit from ZFS performance and integrity
|
||||
|
||||
#### 📧 Email Alerts Configuration
|
||||
I configured my old Debian OS to [send email via Amazon SES]({{< relref "posts/email-alerts">}}), so I will want to replicate this in TrueNAS SCALE. Luckily, I do not have to re-implement my email alerts since this feature is built-in to the OS. I just need to configure a few things in TrueNAS GUI:
|
||||
|
||||
{{< image
|
||||
src="images/truenas-scale-email-alert-configuration.png"
|
||||
caption="📧 TrueNAS SCALE alert email configuration"
|
||||
width="400" >}}
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/truenas-scale-alert-email-test.png"
|
||||
caption="✅ TrueNAS SCALE alert email test success" >}}
|
||||
|
||||
That is pretty much it.
|
||||
|
||||
### 2️⃣ MACHINE 2 (SERVER)
|
||||
|
||||
**Windows + Hyper-V**
|
||||
|
||||
This machine runs on relatively powerful hardware, which I would not like to waste by handing it *all* over to container services. The host OS will remain **Windows**, which I will use as a normal workstation. The **Docker** and **Kubernetes** services will run in virtual machines under **Hyper-V** hypervisor.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph host1["SERVER (Windows)"]
|
||||
subgraph hypervisor["Hypervisor (Hyper-V)"]
|
||||
subgraph vm1[Ubuntu Server VM 1]
|
||||
docker[Docker]
|
||||
end
|
||||
subgraph vm2[Ubuntu Server VM 2]
|
||||
kubernetes[Kubernetes host]
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
#### 🔂 Process
|
||||
All I need to do here is install Windows OS on the machine and provision 2 VMs. The basic setup process is as follows:
|
||||
- Make bootable USB with [Etcher tool](https://etcher.balena.io/)
|
||||
- Make backups of important files:
|
||||
- Basically nothing. Once again, everything I care about is stored on the NAS server pool.
|
||||
- Boot into Windows installer environment and install OS
|
||||
- Configure 2 VMs:
|
||||
- Ubuntu Server (Docker host)
|
||||
- Ubuntu Server (Kubernetes host)
|
||||
|
||||
✅ Done. Basic desktop service and container infrastructure is restored to Server.
|
||||
|
||||
{{< image
|
||||
src="images/windows-hyper-v-containers.png"
|
||||
caption="Windows Hyper-V containers" >}}
|
||||
|
||||
#### 🖥️ VM Configuration
|
||||
|
||||
- Install Ubuntu Server on each VM
|
||||
- On Kubernetes VM:
|
||||
- Install Snap Microk8s package
|
||||
- On Docker VM:
|
||||
- Install Docker Engine
|
||||
- Create `Netplan` configuration files:
|
||||
- Static IPs
|
||||
- DNS search address
|
||||
|
||||
### 🌐 DNS Configuration
|
||||
I need to configure my DNS to resolve the correct addresses inside my network. Each service is hosted at its own URL, e.g. `https://personal-website.domain.com`. To reach this, I need:
|
||||
- `A` records for each host
|
||||
- `CNAME` records for each service
|
||||
|
||||
|Record Type|Name|Value|
|
||||
|-|-|-|
|
||||
|A|docker.domain.com|192.168.1.2|
|
||||
|A|kubernetes.domain.com|192.168.1.3|
|
||||
|CNAME|personal-website.domain.com|kubernetes.domain.com|
|
||||
|CNAME|pastebin.domain.com|docker.domain.com|
|
||||
|
||||
This set of DNS records will resolve client requests to the right hosts. On each host, traffic will need to be routed to the right container/service. To do this on the Docker host, I use **NGINX reverse proxy** to route incoming requests to the correct backend container based on the domain name.
|
||||
|
||||
**e.g.**
|
||||
|Service|FQDN|External Port|Container Port|
|
||||
|-|-|-|-|
|
||||
|PasteBin|pastebin.domain.com|443|8000|
|
||||
|
||||
On the Kubernetes host, I use **Ingress** controller (NGINX-based) that serves as the reverse proxy in the same way.
|
||||
|
||||
---
|
||||
|
||||
### 👀 Infrastructure Diagrams
|
||||
I diagram the relationships between the NAS server and the Linux and Windows clients below. I produce 2 different diagrams only for clarity. Also for clarity, I only show **example** services and storage, not the actual configuration present on my machine.
|
||||
|
||||
**🗺️ Infrastructure topology (Windows Client)**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph windows[Windows]
|
||||
n[nicholas]
|
||||
user2[User 2]
|
||||
end
|
||||
subgraph nas[NAS]
|
||||
subgraph smb[SMB Shares]
|
||||
smbuser2[User 2 Data]
|
||||
smbn[Nicholas Data]
|
||||
end
|
||||
end
|
||||
|
||||
n <-- SMB share --> smbn
|
||||
user2 <-- SMB share --> smbuser2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🗺️ Infrastructure topology (Linux Clients)**
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph windows[Windows VMs]
|
||||
subgraph kubernetes[Kubernetes]
|
||||
weblog[Web Log]
|
||||
end
|
||||
subgraph docker[Docker]
|
||||
pastebin[PasteBin]
|
||||
end
|
||||
end
|
||||
subgraph nas[NAS]
|
||||
subgraph nfs[NFS Shares]
|
||||
weblogfiles[Web Log Files]
|
||||
pastebindata[Pastebin Files]
|
||||
end
|
||||
end
|
||||
|
||||
weblogfiles <-- Kubernetes Persistent Volume --> weblog
|
||||
pastebindata <-- Docker Volume --> pastebin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Summary
|
||||
- Move containers/services off NAS server
|
||||
- Switch NAS server OS to TrueNAS SCALE for purpose-built storage management features
|
||||
- Configure two VMs that become new Kubernetes and Docker hosts
|
||||
- Host VMs in Hyper-V hypervisor on Windows OS
|
||||
- Centralize persistent container storage in Kubernetes and Docker using NAS server NFS/SMB shares
|
||||
|
||||
✅ Done.
|
||||
@@ -0,0 +1,204 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["docker"]
|
||||
date = 2024-04-18T17:00:33Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "ipcam-timelapse"
|
||||
title = "📹 IP Camera Time-lapse in Frigate"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I have a container running `Frigate`, which captures and republishes a feed from my security camera overlooking the area beyond my deck. I will periodically capture a frame from this stream for some time, and compile the frames into time-lapses using `ffmpeg`
|
||||
|
||||
{{< image
|
||||
src="images/ip-cam-mounted.JPG"
|
||||
caption="IP camera mounted" >}}
|
||||
|
||||
{{< image
|
||||
src="images/ip-cam-view.JPG"
|
||||
caption="IP camera view of deck overlooking the neighborhood" >}}
|
||||
|
||||
## Docker Container - Frigate
|
||||
As I mention above, I am already using `Frigate` to manage my IP camera stream for things like object detection, recording, and other such functions that are common in video management systems. The software lacks built-in features relating to time-lapses, but this is easily implemented through other means.
|
||||
## Docker Compose
|
||||
First, I include here the basic `docker-compose.yml` file that I use to get a `Frigate` container up and running. Not a lot of software configuration is defined here, this will mostly be done later in the frigate configuration file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
frigate:
|
||||
container_name: frigate
|
||||
privileged: true
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/blakeblackshear/frigate:stable
|
||||
shm_size: "512mb"
|
||||
devices:
|
||||
- /dev/dri/renderD128 # for intel hwaccel
|
||||
volumes:
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
- ${HOST_PATH_CONFIG}:/config
|
||||
- ${HOST_PATH_MEDIA}:/media/frigate
|
||||
- type: tmpfs # Optional: 1GB of memory, reduces SSD/SD Card wear
|
||||
target: /tmp/cache
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
ports:
|
||||
- "${PORT}:5000"
|
||||
- "8554:8554" # RTSP feeds
|
||||
- "8555:8555/tcp" # WebRTC over tcp
|
||||
- "8555:8555/udp" # WebRTC over udp
|
||||
environment:
|
||||
- FRIGATE_CAMERA_USER=${FRIGATE_CAMERA_USER}
|
||||
- FRIGATE_CAMERA_PASSWORD=${FRIGATE_CAMERA_PASSWORD}
|
||||
```
|
||||
|
||||
```
|
||||
PORT=<port number>
|
||||
HOST_PATH_CONFIG=<host path for config>
|
||||
HOST_PATH_MEDIA=<host path for media>
|
||||
```
|
||||
|
||||
This is my frigate configuration file config.yml. In this configuration, I am not recording or capturing snapshots of anything, which are major features of frigate. Instead I am using frigate to monitor the feed in real time and to periodically grab frames from the stream it publishes. In other words, it is superfluous to use frigate for the purpose of capturing frames. One could go straight to the source RTSP stream and circumvent the re-stream. But I do this anyway because sometimes I like to capture videos of squirrels and birds using frigate's object detection.
|
||||
|
||||
```yaml
|
||||
ui:
|
||||
live_mode: mse
|
||||
|
||||
mqtt:
|
||||
enabled: false
|
||||
|
||||
go2rtc:
|
||||
streams:
|
||||
deck:
|
||||
- rtsp://{FRIGATE_CAMERA_USER}:{FRIGATE_CAMERA_PASSWORD}@192.168.1.200:554//h265Preview_01_main
|
||||
|
||||
birdseye:
|
||||
enabled: false
|
||||
mode: continuous
|
||||
|
||||
cameras:
|
||||
deck: # <------ Name the camera
|
||||
objects:
|
||||
track:
|
||||
- person
|
||||
- animal
|
||||
ffmpeg:
|
||||
inputs:
|
||||
- path: rtsp://127.0.0.1:8554/deck
|
||||
roles:
|
||||
- record
|
||||
- detect
|
||||
hwaccel_args: preset-vaapi
|
||||
input_args: -tag:v hvc1
|
||||
output_args:
|
||||
record: preset-record-generic-audio-copy
|
||||
|
||||
record:
|
||||
enabled: false
|
||||
events:
|
||||
pre_capture: 2
|
||||
post_capture: 2
|
||||
objects:
|
||||
- animal
|
||||
|
||||
snapshots: # <----- Enable snapshots
|
||||
enabled: false
|
||||
|
||||
detect:
|
||||
enabled: false
|
||||
width: 3840
|
||||
height: 2160
|
||||
|
||||
detectors:
|
||||
ov:
|
||||
type: openvino
|
||||
device: AUTO
|
||||
model:
|
||||
path: /openvino-model/ssdlite_mobilenet_v2.xml
|
||||
|
||||
model:
|
||||
width: 300
|
||||
height: 300
|
||||
input_tensor: nhwc
|
||||
input_pixel_format: bgr
|
||||
labelmap_path: /openvino-model/coco_91cl_bkgr.txt
|
||||
labelmap:
|
||||
15: animal
|
||||
16: animal
|
||||
17: animal
|
||||
```
|
||||
|
||||
|
||||
|
||||
```shell
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
|
||||
|
||||
# Time-lapse Parameters
|
||||
|
||||
The goal is to compress a month (30 days) of time into a minute.
|
||||
|
||||
|
||||
## Clip Duration
|
||||
|
||||
The view from my deck is not so exciting, so I do not want time-lapse clip to last too long. I settled on 1 minute.
|
||||
|
||||
|
||||
## Frame rate
|
||||
|
||||
I want the video to be very smooth, so I chose 60 frames per second. This should be plenty. I can change my mind when I generate a video using ffmpeg's -framerate {framerate} option because I am storing each individual frame as a .png file.
|
||||
|
||||
|
||||
## Interval
|
||||
|
||||
The interval follows from the frame rate and clip duration. The interval is a function of clip duration and frame rate.
|
||||
|
||||
The relationship between these variables can be expressed:
|
||||
|
||||
duration = frames * framerate
|
||||
interval = duration / framerate
|
||||
|
||||
|
||||
|
||||
# Capturing Frames from Frigate
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
|
||||
time=`date '+%Y_%m_%d__%H_%M_%S'`;
|
||||
|
||||
StorageFileName=$time.png
|
||||
|
||||
/usr/bin/ffmpeg -rtsp_transport tcp -i rtsp://<username>:<password>@<IP address>/Preview_<channel number>_<stream type> -ss 1 -frames:v 1 -update 1 <path>/$StorageFileName
|
||||
```
|
||||
|
||||
Here we use ffmpeg to capture the frames and store them as png files according to the time it was captured. This script runs every 12 minutes via cron:
|
||||
|
||||
|
||||
```sh
|
||||
crontab -e
|
||||
*/12 * * * * <path_to_timelapse.sh>
|
||||
```
|
||||
|
||||
This is the command I use to generate a video.
|
||||
|
||||
|
||||
```shell
|
||||
ffmpeg -framerate 60 -pattern_type glob -i '*.png' -c:v libx265 -crf 28 -preset ultrafast -s 2k 28_ultrafast.mp4
|
||||
```
|
||||
{{< image
|
||||
src="images/timelapse.gif"
|
||||
caption="Timelapse" >}}
|
||||
|
||||
{{< video
|
||||
src="videos/timelapse-multiday.mp4"
|
||||
width="100%" >}}
|
||||
|
||||
{{< video
|
||||
src="videos/timelapse-deck-planter.mp4"
|
||||
width="100%" >}}
|
||||
|
||||
|
||||
ReoLink RLC-810WA
|
||||
@@ -0,0 +1,319 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins","nginx"]
|
||||
date = 2025-04-06T10:18:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins-deploy-web-log"
|
||||
title = "🚀 Hugo Site Deployment - Automating and Version Tracking"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I am using the static site generator `Hugo` to generate the web log site that currently hosts this content. Currently, my site is hosted on my local network using `hugo server`:
|
||||
|
||||
> Hugo provides its own webserver which builds and serves the site. While hugo server is high
|
||||
> performance, it is a webserver with limited options.
|
||||
|
||||
This has worked great since I have been using the embedded webserver for development purposes -- each time I make changes to the watched files, Hugo rebuilds and live-reloads the site. This is convenient for development purposes, so I will continue to use it in this way.
|
||||
|
||||
For production, though, since I will be publishing the site to the public internet, I will use a more configurable webserver, **Nginx**, to serve the site.
|
||||
|
||||
I will also begin to use Git in my workflow, which will require some configuration on its own, including **Git LFS**, and **hooks**.
|
||||
I will create a Jenkins agent capable of building a hugo site. For this I will build a custom Docker image based on the **Jenkins inbound agent**.
|
||||
|
||||
---
|
||||
|
||||
## Git
|
||||
Currently, my site is not edited using any kind of version control. I simply save files to a directory and the built-in Hugo server serves the site to the local machine. I will develop a new workflow so that changes are tracked and managed via **Git**. This will introduce a little complexity, but worth the benefits:
|
||||
- Changes made to the project will be tracked, allowing for easy rollback to previous versions and a clear history of changes.
|
||||
- Storing the site in a Git repository protects against data loss/corruption, as the original code will always be accessible remotely or among a local clone.
|
||||
|
||||
|
||||
### Git LFS
|
||||
My site contains many images and videos, which are relatively large files and are therefore not handled well by Git by default. I will use **Git LFS** to manage these file types so that my repo does not become slow. For each file type I wish to be managed by Git LFS (i.e. `.jpg`, `.mp4`, `.webm`, etc.), I run `git lfs track "*.jpg"` in the repository root. This creates an entry in `.gitattributes`, a file which Git uses to match and intercept committed files to store them in the repository as pointers, instead of the actual image or video. The actual data is stored outside of the repo, in the LFS data storage I have configured on Gitea.
|
||||
|
||||
#### `.gitattributes`
|
||||
```gitattributes
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.PNG filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.JPG filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.MP4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.webm filter=lfs diff=lfs merge=lfs -text
|
||||
```
|
||||
|
||||
### Git Submodules
|
||||
Since my repo contains a Hugo theme that is itself a git repo, which was added to the repo as a submodule, I will need to be sure to include the theme repo when I clone from the repository, using the flag `--recurse-submodules`, e.g. `git clone --recurse-submodules https://git.uuard.com/nicholas/web-log.git`
|
||||
|
||||
This will be relevant when I write configure a Jenkins pipeline to clone and build the site. It will need to include some way of cloning the submodules.
|
||||
|
||||
---
|
||||
|
||||
## NGINX Webserver
|
||||
I run a NGINX webserver on my host machine, whose current primary purpose is to serve as a reverse proxy for my varied local docker services. I will modify the configuration so that I can serve my web log site.
|
||||
|
||||
I need to create a file in `/etc/nginx/sites-available/` and symlink it to `/etc/nginx/sites-enabled/`. This is a typical pattern for managing multiple sites that allows me to organize the configurations, one configuration file per site.
|
||||
|
||||
#### `web-log` Site Configuration
|
||||
- This configuration file will serve my web log at `log.nicholas.uuard.com`. Nginx listens on both port `80` and port `443`. The first server block listens on port 80, and will perform a redirect request from the URL over HTTP (port 80) to HTTPS (port 443). In the second server block, the `root` is set to `/var/www/web-log/public`, which is the host directory where the Jenkins build agent container will copy the built Hugo site.
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name log.nicholas.uuard.com www.log.nicholas.uuard.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name log.nicholas.uuard.com www.log.nicholas.uuard.com;
|
||||
server_tokens off;
|
||||
ssl_certificate /etc/letsencrypt/live/uuard.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/uuard.com/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers 'TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384';
|
||||
ssl_prefer_server_ciphers on;
|
||||
root /var/www/web-log/public;
|
||||
index index.html index.htm;
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
```
|
||||
---
|
||||
Now I can symlink the file to the `sites-enabled` directory. Nginx loads configuration files from the `sites-enabled` directory at startup or when the configuration is reloaded, so I will need to to reload the `nginx` service:
|
||||
|
||||
```shell
|
||||
sudo ln -s /etc/nginx/sites-available/web-log /etc/nginx/sites-enabled/
|
||||
sudo service nginx reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Custom Jenkins Agent Image
|
||||
Since I will be building my site on a Jenkins agent, it will need to be able to run `hugo` commands. I will create a custom Dockerfile to build a new image from the `jenkins/inbound-agent` base image that includes the `hugo` binary.
|
||||
|
||||
### Dockerfile
|
||||
```Dockerfile
|
||||
FROM jenkins/inbound-agent:latest-jdk17
|
||||
|
||||
USER root
|
||||
|
||||
# set hugo version, architecture
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
HUGO_VERSION=0.145.0 \
|
||||
HUGO_ARCH=Linux-64bit
|
||||
|
||||
# install wget, hugo; cleanup
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends wget && \
|
||||
wget -q https://github.com/gohugoio/hugo/releases/download/v${HUGO_VERSION}/hugo_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz && \
|
||||
tar -xzf hugo_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz && \
|
||||
mv hugo /usr/bin/hugo && \
|
||||
chmod +x /usr/bin/hugo && \
|
||||
rm hugo_${HUGO_VERSION}_${HUGO_ARCH}.tar.gz && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
### Build Custom Jenkins Agent
|
||||
Now I need to build the image:
|
||||
`docker build -t jenkins-agent-hugo .`
|
||||
|
||||
```shell
|
||||
[+] Building 6.9s (6/6) FINISHED docker:default
|
||||
=> [internal] load build definition from Dockerfile 0.0s
|
||||
=> => transferring dockerfile: 689B 0.0s
|
||||
=> [internal] load metadata for docker.io/jenkins/inbound-agent:latest-jdk17 0.5s
|
||||
=> [internal] load .dockerignore 0.0s
|
||||
=> => transferring context: 2B 0.0s
|
||||
=> CACHED [1/2] FROM docker.io/jenkins/inbound-agent:latest-jdk17@sha256:6b4 0.0s
|
||||
=> [2/2] RUN apt-get update && apt-get install -y --no-install-recommend 5.7s
|
||||
=> exporting to image 0.5s
|
||||
=> => exporting layers 0.5s
|
||||
=> => writing image sha256:ae480b018a3928e5c0dede760431eff64a2cabec46e266ab3 0.0s
|
||||
=> => naming to docker.io/library/jenkins-agent-hugo 0.0s
|
||||
```
|
||||
|
||||
This will create a docker image named `jenkins-agent-hugo`. I verify this by listing the docker images:
|
||||
`docker image list`.
|
||||
|
||||
| REPOSITORY | TAG | IMAGE ID | CREATED | SIZE |
|
||||
| ------------------ | ------ | ------------ | ------------- | ----- |
|
||||
| jenkins-agent-hugo | latest | ae480b018a39 | 2 minutes ago | 336MB |
|
||||
|
||||
---
|
||||
|
||||
## Jenkins Node (agent)
|
||||
I have created the Docker image necessary to run a containerized build agent that will be able to build and deploy the Hugo website. Before I can run the container though, I need to create and configure a Jenkins node that corresponds to the agent. This will allow me to connect the Jenkins orchestrator to the container agent.
|
||||
|
||||
- **Name**: "hugo"
|
||||
- **Launch method**: Launch agent by connecting it to the controller
|
||||
- **Home directory**: `/home/jenkins`
|
||||
- **Labels**: "hugo"
|
||||
|
||||
I save the configuration and navigate to the Agent status page where Jenkins provides a command to run the agent. I do not run this command, but extract the `secret` value for later use in the agent container.
|
||||
|
||||
This is an example of the command Jenkins provides. I need the string that follows `... -secret`
|
||||
```shell
|
||||
curl -sO https://build.uuard.com/jnlpJars/agent.jar;java -jar agent.jar -url https://build.uuard.com/ -secret f1f50517be8c46348997c91fb05c5ad73cb13a761a4cce0e5dfcfc6ffb5d8afe -name hugo -webSocket -workDir "/home/jenkins"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Jenkins Pipeline
|
||||
I set up a new Jenkins **pipeline** item named `web-log-pipeline` with basically the same configuration as in my [other post]({{< relref "posts/jenkins-deploy-website/#improvements---git-hook" >}}) that covers this. One difference is in the *Advanced sub-modules behaviors*. I want to enable the *Recursively update submodules* option so that the `checkout scm` step of the Build stage of the pipeline will also clone the theme submodule.
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-update-submodules.jpg"
|
||||
caption="recursively update submodules" >}}
|
||||
|
||||
That is all.
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose
|
||||
As usual, I will configure my docker container with a compose file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jenkins-agent-hugo:
|
||||
container_name: jenkins-agent-hugo
|
||||
hostname: jenkins-agent-hugo
|
||||
image: jenkins-agent-hugo
|
||||
init: true
|
||||
command: ["-url", "${AGENT_URL}", "${AGENT_SECRET}", "${AGENT_NAME}"]
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- /var/www/:/mnt/jenkins_deployments/
|
||||
```
|
||||
This file references environment variables stored in `.env`
|
||||
The `AGENT_SECRET` value came from the step above.
|
||||
|
||||
```env
|
||||
AGENT_URL=https://build.uuard.com
|
||||
AGENT_SECRET=4897df9u4jf...
|
||||
AGENT_NAME=hugo
|
||||
```
|
||||
The container is configured for a bind-mount that mounts the host directory `/var/www/` to the container directory `/mnt/jenkins_deployments/`.
|
||||
|
||||
|
||||
The Jenkins agent will be configured to build to `/mnt/jenkins_deployments/web-log/public`, which maps to the Nginx site directory on the host: `/var/www/web-log/public`.
|
||||
|
||||
Once the container is running, I can check its status in Jenkins and verify that the agent can build hugo sites.
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-hugo-connected.jpg"
|
||||
caption="Jenkins 'hugo' agent status" >}}
|
||||
|
||||
I can visit the **Script Console** and run the following groovy script. This will print out what version of Hugo is installed.
|
||||
|
||||
```groovy
|
||||
def hugoVersion = "hugo version".execute().text.trim()
|
||||
println "Hugo version: ${hugoVersion}"
|
||||
```
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-hugo-version.jpg"
|
||||
caption="hugo version confirmed" >}}
|
||||
|
||||
This confirms that my agent has `hugo` installed. I will be able to run builds using this agent.
|
||||
|
||||
---
|
||||
|
||||
## Configuring Jenkins Agent
|
||||
I will include this `Jenkinsfile` in the repository. The Jenkins pipeline will be configured according to this document.
|
||||
### Jenkinsfile
|
||||
```Jenkinsfile
|
||||
pipeline {
|
||||
agent { label 'hugo' }
|
||||
environment {
|
||||
DEPLOY_DIR = '/mnt/jenkins_deployments/web-log'
|
||||
}
|
||||
stages {
|
||||
stage('checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
stage('hugo build') {
|
||||
steps {
|
||||
sh 'hugo --environment production --minify'
|
||||
}
|
||||
}
|
||||
stage('deploy') {
|
||||
steps {
|
||||
sh """
|
||||
find ${DEPLOY_DIR} -mindepth 1 -delete
|
||||
cp -r ${WORKSPACE}/public ${DEPLOY_DIR}/
|
||||
chmod -R 755 ${DEPLOY_DIR}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo 'Build failed!'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
#### Jenkinsfile pipeline definition details
|
||||
- **Pipeline Declaration**
|
||||
- Defines a declarative pipeline with `pipeline {}` block.
|
||||
|
||||
- **Agent**
|
||||
- `agent { label 'hugo' }` → Runs on any agent with the label 'hugo'.
|
||||
|
||||
- **Environment Variables**
|
||||
- `DEPLOY_DIR = '/mnt/jenkins_deployments/web-log'`: Sets the deployment directory path, which corresponds to the bind-mount defined in the `docker-compose.yml` file. This maps to `/var/www/web-log` on the host.
|
||||
|
||||
- **Stage: checkout**
|
||||
- `checkout scm` Retrieves the source code from gitea (scm is configured in the Jenkins pipeline)
|
||||
|
||||
- **Stage: hugo build**
|
||||
- `hugo --environment production --minify` builds and minifies the hugo site. Sets the `environment` to `production` with the `--environment` flag. Outputs to `public` directory in the workspace.
|
||||
|
||||
- **Stage: deploy**
|
||||
- Runs shell commands in `sh """ ... """` block:
|
||||
- `find ${DEPLOY_DIR} -mindepth 1 -delete` → Deletes existing files in the deployment directory.
|
||||
- `cp -r ${WORKSPACE}/public ${DEPLOY_DIR}/` → Copies all files from the `public` directory in the workspace to the deployment directory.
|
||||
- `chmod -R 755 ${DEPLOY_DIR}` → Sets permissions for readability and execution.
|
||||
|
||||
- **Post Actions**
|
||||
- `post { failure { echo "Build Failed" } }` → Displays `"Build Failed"` if any stage fails.
|
||||
|
||||
|
||||
|
||||
### Jenkins Pipeline configuration
|
||||
I use mostly the same configuration as here:
|
||||
|
||||
[Jenkins Pipeline Configuration]({{< relref "posts/jenkins-deploy-website/#jenkins-pipeline-configuration" >}})
|
||||
|
||||
```yaml
|
||||
Definition: Pipeline script from SCM
|
||||
SCM: Git
|
||||
Repositories:
|
||||
- Repository URL: https://git.uuard.com/nicholas/web-log.git
|
||||
- Credentials: gitea
|
||||
Branches to build:
|
||||
- Branch Specifier: */master
|
||||
Script Path: Jenkinsfile
|
||||
```
|
||||
|
||||
## Git Hook
|
||||
To be fully automated, I need to configure a Git hook that will trigger the Jenkins agent to build and deploy the site. Since I cover this in a [previous post]({{< relref "posts/jenkins-deploy-website/#improvements---git-hook" >}}), I do not repeat myself here. There is nothing really different about this hook except the `POST` request URL defined in the script. The pipeline has a different name `web-log-pipeline` and therefore a different URL.
|
||||
|
||||
## Result
|
||||
Jenkins successfully deployed the Hugo site.
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-build-hugo-success.jpg"
|
||||
caption="Successful hugo site deployment" >}}
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,191 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins","nginx"]
|
||||
date = 2025-03-30T18:00:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins-deploy-website"
|
||||
title = "🤵🏻 Automating Personal Website Deployment"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I have a simple [personal website](https://nicholas.uuard.com/) that currently serves as a landing page containing links that direct visitors to my other websites. I want to use Jenkins to automate the deployment of this site. In my [last post on this topic]({{< relref "posts/jenkins" >}}), I set up a Jenkins web UI and build agent in container using Docker. I will use this container to automate the process of deploying my website.
|
||||
|
||||
## The plan
|
||||
I will configure Jenkins to monitor the `main` branch of my [website repository](https://git.uuard.com/Ward/personal-website) and copy the repo to a host directory that is bind-mounted to both my Jenkins and Nginx containers. This host directory will be bind-mounted to Nginx's document root so the website is automatically published to the web server.
|
||||
|
||||
## Jenkins
|
||||
I am using a pipeline definition based on a **pipeline script from SCM**. Instead of storing the pipeline script in Jenkins directly, I will use the script defined in the repository. This has the benefit of being much easier to maintain, as I only need to make changes to the repository. It fully automates the process by eliminating the step where I must configure the pipeline script before I push changes. Below is the Jenkinsfile I used to test my setup.
|
||||
|
||||
### Jenkins pipeline configuration
|
||||
- First, I need to add a new pipeline item to Jenkins.
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-new-pipeline.jpg"
|
||||
caption="New pipeline 'personal-website-pipeline'" >}}
|
||||
|
||||
- Next I configure the pipeline with the following settings:
|
||||
{{< image
|
||||
src="images/jenkins-pipeline-configuration.jpg"
|
||||
caption="person-website-pipeline settings" >}}
|
||||
|
||||
I have formatted the relevant settings in YAML below:
|
||||
```yaml
|
||||
Definition: Pipeline script from SCM
|
||||
SCM: Git
|
||||
Repositories:
|
||||
- Repository URL: https://git.uuard.com/Ward/personal-website.git
|
||||
- Credentials: gitea
|
||||
Branches to build:
|
||||
- Branch Specifier: */main
|
||||
Script Path: Jenkinsfile
|
||||
```
|
||||
#### Jenkins pipeline configuration details
|
||||
- **Pipeline Definition**
|
||||
- The pipeline is defined as a **Pipeline script from SCM**, meaning the Jenkinsfile is stored in the repository rather than inside Jenkins itself.
|
||||
|
||||
- **Source Code Management (SCM) Settings**
|
||||
- **SCM: Git** → Jenkins fetches the pipeline from a Git repository.
|
||||
- **Repository URL: `https://git.uuard.com/Ward/personal-website.git`** → This is the Git repository where the Jenkinsfile and source code are stored.
|
||||
- **Credentials: `gitea`** → Authentication is handled using stored credentials for Gitea.
|
||||
|
||||
- **Branch to Build**
|
||||
- **Branch Specifier: `*/main`** → Jenkins monitors and builds from the `main` branch.
|
||||
|
||||
- **Script Path**
|
||||
- **`Jenkinsfile`** → Jenkins looks for the pipeline script inside the repository at this path.
|
||||
|
||||
|
||||
### Jenkinsfile pipeline definition
|
||||
```Jenkinsfile
|
||||
pipeline {
|
||||
agent any
|
||||
environment {
|
||||
DEPLOY_DIR = '/var/jenkins_home/deployments/personal-website'
|
||||
}
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
stage('copy to nginx server') {
|
||||
steps {
|
||||
sh """
|
||||
rm -rf ${DEPLOY_DIR}/*
|
||||
cp -r * ${DEPLOY_DIR}/
|
||||
chmod -R 755 ${DEPLOY_DIR}
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo "Build Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
#### Jenkinsfile pipeline definition details
|
||||
- **Pipeline Declaration**
|
||||
- Defines a declarative pipeline with `pipeline {}` block.
|
||||
|
||||
- **Agent**
|
||||
- `agent any` → Runs on any available Jenkins agent. I am currently building on the built-in node (agent).
|
||||
|
||||
- **Environment Variables**
|
||||
- `DEPLOY_DIR = '/var/jenkins_home/deployments/personal-website'`: Sets the deployment directory path. This path is inside the Docker volume `jenkins-data`. The `var/jenkins_home/deployments/personal-website` directory has been bind-mounted to a directory on the host machine, which is also shared as a bind-mount to the document root of Nginx web server running in a separate Docker container.
|
||||
|
||||
- **Stage: Checkout**
|
||||
- `checkout scm` Retrieves the source code from gitea (scm is configured in the Jenkins pipeline)
|
||||
|
||||
- **Stage: Copy to Nginx Server**
|
||||
- Runs shell commands in `sh """ ... """` block:
|
||||
- `rm -rf ${DEPLOY_DIR}/*` → Deletes existing files in the deployment directory.
|
||||
- `cp -r * ${DEPLOY_DIR}/` → Copies all files from the workspace to the deployment directory.
|
||||
- `chmod -R 755 ${DEPLOY_DIR}` → Sets permissions for readability and execution.
|
||||
|
||||
- **Post Actions**
|
||||
- `post { failure { echo "Build Failed" } }` → Displays `"Build Failed"` if any stage fails.
|
||||
|
||||
### Jenkins Container Configuration
|
||||
I need to modify the `docker-compose.yml` file to reflect my description above of how the whole system should work. I add a bind-mount to the **host** directory `nginx-personal-website/html` (which my webserver will also have bind-mounted) to the **container** directory at `var/jenkins_home/deployments/personal-website`. In the pipeline definition above I set the `DEPLOY_DIR` to map to this container directory.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jenkins:
|
||||
image: jenkins/jenkins:jdk17
|
||||
container_name: jenkins
|
||||
hostname: jenkins
|
||||
restart: always
|
||||
volumes:
|
||||
- jenkins-data:/var/jenkins_home/
|
||||
- ../nginx-personal-website/html:/var/jenkins_home/deployments/personal-website
|
||||
ports:
|
||||
- "2376:2376"
|
||||
- "8882:8080"
|
||||
- "50000:50000"
|
||||
|
||||
volumes:
|
||||
jenkins-data:
|
||||
external: true
|
||||
```
|
||||
## Nginx
|
||||
I will use an Nginx container for my web server. The container will simply serve whatever content is in the document root, in this case `/usr/share/nginx/html`. This is the behavior of Nginx, and I can change this by supplying a different configuration. For now, the default configuration is enough. I can add custom configuration later.
|
||||
|
||||
### Nginx Container
|
||||
```yaml
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: nginx-personal-website
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8800:80"
|
||||
volumes:
|
||||
- ./html:/usr/share/nginx/html:ro
|
||||
#- ./conf:/etc/nginx:ro
|
||||
```
|
||||
|
||||
## Testing
|
||||
As soon as I push a change to the `main` branch of my repo, Jenkins should execute the pipeline script defined in the `Jenkinsfile`.
|
||||
{{< image
|
||||
src="images/jenkins-pipeline-run-overview.jpg"
|
||||
caption="Jenkins test build - success" >}}
|
||||
|
||||
## Improvements - Git Hook
|
||||
Polling is bad and Jenkins agrees. In the tooltip for selecting `Poll SCM` build trigger:
|
||||
|
||||
> Note that this is going to be an expensive operation for CVS, as every polling requires
|
||||
> Jenkins to scan the entire workspace and verify it with the server. Consider setting up a
|
||||
> "push" trigger to avoid this overhead, as described in this document
|
||||
|
||||
So instead I select **Trigger builds remotely (e.g., from scripts)**
|
||||
|
||||
{{< image
|
||||
src="images/jenkins-trigger-build-remotely.jpg"
|
||||
caption="trigger builds remotely" >}}
|
||||
|
||||
To accomplish triggering builds remotely, I will implement a hook that executes a script after I push to the repository. The script will send a post request to Jenkins which will trigger the build.
|
||||
|
||||
This script goes in the `gitea/data/git/repositories/<user>/<repository.git>/hooks/post-receive.d` directory. The way this works is Gitea first automatically runs a built-in `post-receive` hook, which is a script that iterates through all of the files inside the `post-receive.d` directory and executes them as well.
|
||||
|
||||
Here is the script I use to trigger a build:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
JENKINS_URL="https://build.uuard.com"
|
||||
JENKINS_JOB="/job/personal-website-pipeline/build"
|
||||
JENKINS_USER="nicholas"
|
||||
JENKINS_API_TOKEN="<api-token>"
|
||||
JENKINS_BUILD_URL="$JENKINS_URL$JENKINS_JOB"
|
||||
|
||||
curl -X POST -u "$JENKINS_USER:$JENKINS_API_TOKEN" "$JENKINS_BUILD_URL"
|
||||
|
||||
exit 0
|
||||
```
|
||||
|
||||
When Jenkins receives the HTTP POST request at `https://build.uuard.com/job/personal-website-pipeline/build` with valid authentication it will initiate the build.
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,198 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["automation","docker","jenkins"]
|
||||
date = 2024-10-31T15:49:57Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "jenkins"
|
||||
title = "🐋 Configuring Development Pipeline with Jenkins & Docker"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I have a few Gitea git repositories that I host on my local network. Eventually I would like to be able to configure my server to automatically build and deploy the latest changes to these repositories to a development site. In this post, I will configure only the infrastructure that will make this possible.
|
||||
|
||||
|
||||
# First Steps
|
||||
The first thing I do with any project that runs in a container is to give it its own directory. I create the Docker Compose configuration file that will describe the docker container.
|
||||
|
||||
```shell
|
||||
nicholas@nas:/$ cd /hdd1/nicholas/docker/dockerfiles/
|
||||
nicholas@nas:/.../$ mkdir jenkins && cd jenkins
|
||||
nicholas@nas:/.../jenkins/$ nano docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
jenkins:
|
||||
image: jenkins/jenkins:jdk17
|
||||
container_name: jenkins
|
||||
hostname: jenkins
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data:/var/jenkins_home/
|
||||
ports:
|
||||
- "2376:2376"
|
||||
- "8882:8080"
|
||||
- "50000:50000"
|
||||
```
|
||||
|
||||
## Explanation
|
||||
* `restart: always`: Configures the container to always restart automatically if it stops unexpectedly. This keeps Jenkins running persistently.
|
||||
* `volumes`: Mounts folders on my host to a folder inside the container:
|
||||
* `./data:/var/jenkins_home/`: Maps the local data folder to `/var/jenkins_home/` inside the container for data persistence across restarts (Jenkins stores all its configuration, plugins, and job data in `/var/jenkins_home`)
|
||||
* `ports`: Maps ports on the host to ports inside the container:
|
||||
* `2376:2376`: This port is typically used by Docker daemons for secure Docker client connections.
|
||||
* `8882:8080`: Maps port 8080 inside the container (Jenkins’ web UI port) to port 8882 on the host. I access Jenkins at `http://host:8882`.
|
||||
* `50000:50000`: Maps the Jenkins agent communication port (50000) for connecting with Jenkins agents.
|
||||
|
||||
### Create a data folder for Jenkins to use as a bind mount
|
||||
|
||||
```sh
|
||||
mkdir data
|
||||
```
|
||||
### Start the container
|
||||
|
||||
```shell
|
||||
sudo docker compose up -d
|
||||
```
|
||||
|
||||
This will start the container. Now I can access it at `http://nas:8882`, but in a few steps I have not described in this post, I have configured my nginx reverse proxy to forward requests at `https://build.uuard.com` to the underlying docker container at `http://nas:8882`. I can use these two addresses interchangeably – they point to the same container. Before I can do any more configuration, I need to complete the Getting Started section of the web UI, which is mostly is self-explanatory.
|
||||
|
||||
|
||||
|
||||
In my compose file, I mapped the host directory `./data` to the container directory `/var/jenkins_home`. Therefore, I am able to read the password at `./data/secrets/initialAdminPassword`
|
||||
|
||||
```shell
|
||||
cd data/secrets && cat initialAdminPassword
|
||||
7ca8db936d794e78a70df88a1b9c2c1h
|
||||
```
|
||||
|
||||
### Finishing Initial Setup
|
||||
I selected 'Install suggested plugins'. I am only trying to get something working quickly, not optimizing. This step shows a very cool dashboard of the installation progress.
|
||||
|
||||
{{< video
|
||||
src="videos/setup.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Installing Gitea Plugin
|
||||
Since I am hosting my own version control software Gitea, as opposed to, for example, GitHub or GitLab, I will need to install and configure the Jenkins Gitea plugin, which is not installed by default via the 'Install suggested plugins' option that I selected in the step above. This does not complicate things much at all. The video below shows the simple installation process, and further down I show the configuration related to the Gitea plugin.
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_install_gitea_plugin.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Configuring Gitea Service Account
|
||||
The next step is to create a service account that Jenkins can use to make requests to Gitea API. I could use my personal Gitea account to create the token, which would simplify things somewhat, but this does not accord with best practices. This will make the setup more robust and extensible. The service user will belong to the same organization as the owner of the repository and will therefore have the same ability to read and write to the repository. I name the account *service_account*. I do not describe this step in any more detail.
|
||||
|
||||
|
||||
### Generating an Access Token
|
||||
I sign in to the service account and generate an access token with only the minimum permissions necessary. I need to copy this access token and preserve it for use in the next step. The video below shows the process of creating an access token.
|
||||
|
||||
{{< video
|
||||
src="videos/gitea_generate_token.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Add Gitea Credentials to Jenkins
|
||||
I use the access token string generated in the step above to create a credential within Jenkins. This credential will be used in the next step. The video below shows the process of creating the gitea credential in Jenkins.
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_add_gitea_credential.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Configure Jenkins Pipeline
|
||||
I create a new Jenkins Item (Pipeline). The configuration options below instruct Jenkins to fetch the pipeline definition (the Jenkinsfile) from a source control management (SCM) system – in this case Gitea. Jenkins will look for a Jenkinsfile in the specified repository build_test.git and branch main. The build trigger for this pipeline is a post-commit hook. After each commit, a script runs that sends a request to Jenkins, which triggers it to check out the latest commit from the specified branch, and execute build steps according to the Jenkinsfile.
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_configure_pipeline.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
* Under ⚙️Build Triggers
|
||||
* Check the box ☑️ Poll SCM – leave poll schedule textarea empty
|
||||
* No schedules so will only run due to SCM changes if triggered by a post-commit hook
|
||||
* Under 〰️Pipeline
|
||||
* Select Pipeline script from SCM
|
||||
* Select Git SCM
|
||||
* Enter Repository URL "https://git.uuard.com/build_test.git"
|
||||
* Select the Gitea credential created in the step above: "gitea"
|
||||
* Enter Branch Specifier "main"
|
||||
* Script path "Jenkinsfile"
|
||||
|
||||
### Jenkinsfile / Pipeline Definition
|
||||
The pipeline definition only has one stage, which I have named Checkout, which uses Jenkins' built-in command checkout to checkout a git repository defined in the scm variable. The scm variable contains the git SCM configuration that was defined in the Jenkins Pipeline Item above. The Jenkinsfile below resides in the git repository root directory.
|
||||
|
||||
```
|
||||
pipeline {
|
||||
agent any
|
||||
stages {
|
||||
stage('Checkout') {
|
||||
steps {
|
||||
checkout scm
|
||||
}
|
||||
}
|
||||
}
|
||||
post {
|
||||
failure {
|
||||
echo "Build Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The video below shows the process of pushing a commit to the repository, the git webhook being sent to Jenkins (invisibly, in the background) and Jenkins receiving the request and executing actions defined in the pipeline Jenkinsfile
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_trigger_build.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Creating Build Agent / Jenkins Node
|
||||
The next step is to create a dedicated build agent that will execute the steps outlined in a build pipeline. I name the node build_test_agent. I select Launch agent by connecting it to the controller. This option is selected because I intend to use a separate docker container to run an inbound Jenkins agent. My Jenkins setup will operate under the master-controller/agent-slave pattern where the master or controller server (My docker container running docker image `jenkins/jenkins:jdk17`) manages the scheduling of jobs, while the agents or slaves (docker container(s) running the docker image jenkins/inbound-agent) execute the jobs as directed by the master/controller. The secret and the name generated after the agent is created will be used to create the build agent in the step below. The video below shows the process of creating such a build agent.
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_create_build_agent.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
### Configure Build Agent
|
||||
|
||||
Once again, I need to create another directory for a Docker Compose definition.
|
||||
```shell
|
||||
nicholas@nas:/$ cd /hdd1/nicholas/docker/dockerfiles/
|
||||
nicholas@nas:/.../$ mkdir jenkins_agent && cd jenkins_agent
|
||||
nicholas@nas:/.../jenkins/$ nano docker-compose.yml
|
||||
```
|
||||
|
||||
### Create docker-compose.yml File
|
||||
```yaml
|
||||
services:
|
||||
jenkins-agent:
|
||||
container_name: jenkins-agent
|
||||
hostname: jenkins-agent
|
||||
image: jenkins/inbound-agent
|
||||
init: true
|
||||
command: ["-url", "https://build.uuard.com", "${AGENT_SECRET}", "${AGENT_NAME}"]
|
||||
restart: always
|
||||
```
|
||||
The `.env` file holds the `AGENT_SECRET` and `AGENT_NAME` environment variables which I reference in the command passed to the Jenkins agent container.
|
||||
|
||||
```
|
||||
AGENT_SECRET=9dc1911f3a151cb37227dcc05467053511b5892e717bf1029add503ae2ffc66b
|
||||
AGENT_NAME=build_test_agent
|
||||
```
|
||||
|
||||
### Start Build Agent
|
||||
```shell
|
||||
sudo docker compose up -d
|
||||
```
|
||||
This starts the container. The video below shows the process of starting the agent container. Notice the build_test_agent status changes from (offline) to Connected. This agent is now available to do work directed by the Jenkins controller.
|
||||
|
||||
{{< video
|
||||
src="videos/jenkins_start_build_agent.webm"
|
||||
width="100%"
|
||||
>}}
|
||||
|
||||
Done! This will give me a foundation on which to build out a development pipeline for any number of my software projects.
|
||||
@@ -0,0 +1,13 @@
|
||||
+++
|
||||
categories = ["travel"]
|
||||
date = 2025-01-13T20:27:44Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "key-west"
|
||||
title = "🏝️ Key West, FL"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
We had our honeymoon in Key West 2025-01-13 - 2025-01-17. I did not take many photos.
|
||||
|
||||
{{< gallerynew name="key-west-gallery" >}}
|
||||
@@ -0,0 +1,100 @@
|
||||
images:
|
||||
- src: "images/airplane-window-sunset.JPEG"
|
||||
alt: "View out of window of an airplane during sunset"
|
||||
caption: "Sunset on the way back"
|
||||
- src: "images/beach.JPEG"
|
||||
alt: "A beach"
|
||||
caption: "Beach"
|
||||
- src: "images/bus-to-bahia-honda.jpg"
|
||||
alt: "Looking out the window on a bus"
|
||||
caption: "Rainy bus to Bahia Honda."
|
||||
- src: "images/celebrity-silhouette-closeup.JPG"
|
||||
alt: "Cruise ship"
|
||||
caption: "Celebrity Crusie ship"
|
||||
- src: "images/celebrity-silhouette-distant.JPG"
|
||||
alt: "A very large cruise ship"
|
||||
caption: "A very large cruise ship"
|
||||
- src: "images/coco-plum-inn-pool.JPEG"
|
||||
alt: "Guests swimming in the outdoor pool at the Coco Plum Inn."
|
||||
caption: "Relaxing by the pool at the Coco Plum Inn."
|
||||
- src: "images/coco-plum-inn-pool.jpg"
|
||||
alt: "Guests relaxing and swimming in the outdoor pool at the Coco Plum Inn."
|
||||
caption: "Enjoying a refreshing dip at the Coco Plum Inn pool."
|
||||
- src: "images/columbia-left-behind.JPEG"
|
||||
alt: "A decaying ship, the USS Columbia, left abandoned in a harbor."
|
||||
caption: "The poignant reminder of the USS Columbia."
|
||||
- src: "images/dead-fish.JPEG"
|
||||
alt: "A dead fish floating on a dark, reflective surface."
|
||||
caption: "A stark image of a decaying fish."
|
||||
- src: "images/dry-tortugas-birds.JPEG"
|
||||
alt: "Seabirds soaring over the Dry Tortugas National Park."
|
||||
caption: "Diverse seabirds inhabiting the Dry Tortugas."
|
||||
- src: "images/dry-tortugas-fort-ocean.JPEG"
|
||||
alt: "The historic Fort Jefferson overlooking the ocean in the Dry Tortugas."
|
||||
caption: "Exploring the historic Fort Jefferson."
|
||||
- src: "images/dry-tortugas-fort-water.JPEG"
|
||||
alt: "The interior of Fort Jefferson, reflecting the sunlight through the windows."
|
||||
caption: "Inside the impressive walls of Fort Jefferson."
|
||||
- src: "images/dry-tortugas-seaplanes.JPEG"
|
||||
alt: "Seaplanes taking off and landing at the Dry Tortugas Airport."
|
||||
caption: "A scenic view of seaplanes in the Dry Tortugas."
|
||||
- src: "images/fort-iguana-encounter.JPEG"
|
||||
alt: "A close-up photograph of a green iguana basking in the sun."
|
||||
caption: "A vibrant green iguana at Fort Iguana."
|
||||
- src: "images/full-moon-palm-tree.JPEG"
|
||||
alt: "A palm tree silhouetted against a full moon in a tropical setting."
|
||||
caption: "The beauty of a full moon over a palm tree."
|
||||
- src: "images/hemmingway-house-cat.JPEG"
|
||||
alt: "A tabby cat sitting on a porch of Ernest Hemingway's house."
|
||||
caption: "A resident feline at Hemingway's house."
|
||||
- src: "images/iguana-closeup.JPG"
|
||||
alt: "A detailed close-up shot of a green iguana's face."
|
||||
caption: "A fascinating close-up of an iguana."
|
||||
- src: "images/iguana-mouth-open.JPG"
|
||||
alt: "A green iguana with its mouth open, possibly feeding."
|
||||
caption: "An iguana with an open mouth."
|
||||
- src: "images/kc-weather.PNG"
|
||||
alt: "A weather map showing rain in the Miami area."
|
||||
caption: "A weather map indicating rainfall."
|
||||
- src: "images/key-west-airport-tarmac.jpg"
|
||||
alt: "Aircraft parked on the tarmac at Miami International Airport."
|
||||
caption: "Aircraft at Miami International Airport."
|
||||
- src: "images/key-west-seaport.jpg"
|
||||
alt: "The bustling Key West seaport with ships and boats."
|
||||
caption: "The vibrant Key West seaport."
|
||||
- src: "images/key-west-weather.PNG"
|
||||
alt: "A weather map showing sunny conditions in Key West."
|
||||
caption: "A weather map displaying clear skies in Key West."
|
||||
- src: "images/lighthouse-window.jpg"
|
||||
alt: "The window of a lighthouse with a blurry sea view."
|
||||
caption: "Looking out from the window of a lighthouse."
|
||||
- src: "images/manatee-closeup.JPG"
|
||||
alt: "A close-up photograph of a manatee swimming."
|
||||
caption: "A gentle giant – a manatee."
|
||||
- src: "images/portuguese-man-o-war.jpg"
|
||||
alt: "The wreck of the Portuguese Man-of-War ship, a historic maritime site."
|
||||
caption: "The preserved wreck of the Portuguese Man-of-War."
|
||||
- src: "images/seaplane.JPEG"
|
||||
alt: "A vintage seaplane landing on the water."
|
||||
caption: "A classic seaplane taking off."
|
||||
- src: "images/seaside-cafe-lobster-pizza.jpg"
|
||||
alt: "A delicious-looking lobster pizza served at a seaside cafe."
|
||||
caption: "Enjoying a seafood pizza by the ocean."
|
||||
- src: "images/small-iguana.JPEG"
|
||||
alt: "A small green iguana in a tropical environment."
|
||||
caption: "A tiny iguana in a lush setting."
|
||||
- src: "images/sunset-mallory-square.JPEG"
|
||||
alt: "The iconic Mallory Square in Key West during sunset."
|
||||
caption: "The colorful Mallory Square in Key West."
|
||||
- src: "images/sunset-mallory-square.jpg"
|
||||
alt: "The colorful Mallory Square in Key West at sunset."
|
||||
caption: "A stunning sunset view of Mallory Square."
|
||||
- src: "images/yankee-freedom-aft-deck.JPEG"
|
||||
alt: "Guests relaxing on the aft deck of the cruise ship Yankee Freedom."
|
||||
caption: "Relaxing on the deck of the Yankee Freedom."
|
||||
- src: "images/yankee-freedom-docked.JPEG"
|
||||
alt: "The cruise ship Yankee Freedom docked in Key West Harbor."
|
||||
caption: "The Yankee Freedom docked in Key West."
|
||||
- src: "images/yankee-freedom.JPEG"
|
||||
alt: "The cruise ship Yankee Freedom sailing through the turquoise waters."
|
||||
caption: "The Yankee Freedom sailing through the Caribbean."
|
||||
@@ -0,0 +1,111 @@
|
||||
+++
|
||||
categories = ["software","technology"]
|
||||
tags = ["llm","docker"]
|
||||
date = 2025-03-12T10:54:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "local-llm-basic-setup"
|
||||
title = "🦙 Self-hosting LLM with Ollama & OpenWebUI"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
I impulsively purchased a NVIDIA RTX 4060 at my local Best Buy. I thought it would be fun to run my own LLMs.In this post, I deploy only a minimal local LLM with web UI.
|
||||
|
||||
## LLMs
|
||||
LLMs continue to improve apace, getting smaller, more efficient, smarter, generally better in every way, every day, relentlessly and with such speed that I really cannot even pretend to have kept up with their developments to any meaningful degree. I merely absorb the general *sense of things*, passively, allowing the vibes of the techno-accelerationist-sphere wash over me. Every week a new set of capabilities are launched with a new version of a new model using a new paradigm. Exciting, truly what a time to be alive. Around the time of this writing, Deepseek released its R1 model which one can run on relatively low-spec hardware (this is true of many earlier models by other organizations, but none produced the same frenzied excitement as this one), so I hopped on the hype wagon and now I have a somewhat powerful reasoning model running on my computer. Running my own LLM locally is just for fun, and as of the time I write this confers no "practical" benefit to me to over using ChatGPT or Deepseek over the web.
|
||||
|
||||
## Docker Machine
|
||||
Typically I run all of my containers on my "server" machine. I have a few related posts about this: [Server build pt. I](), [Server build pt. II](). However, running LLMs locally will require more powerful hardware than my server machine contains. Luckily my [other machine]() does contain powerful enough components, so I can easily run some smaller LLMs e.g. quantized and distilled models.
|
||||
|
||||
|
||||
I wrote a Docker Compose file that defines a service for running Ollama container, with support for GPU acceleration:
|
||||
### Docker Compose File
|
||||
```yaml {filename="compose.yml"}
|
||||
services:
|
||||
ollama:
|
||||
container_name: ollama
|
||||
image: ollama/ollama
|
||||
expose:
|
||||
- 11434/tcp
|
||||
ports:
|
||||
- 11434:11434/tcp
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
capabilities: ["gpu"]
|
||||
count: all
|
||||
volumes:
|
||||
- ollama:/root/.ollama
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
ollama:
|
||||
```
|
||||
### Running the Container
|
||||
To run the container, I simply:
|
||||
```shell
|
||||
docker compose up -d
|
||||
```
|
||||
This will start the container in headless mode.
|
||||
|
||||
### Interacting with LLM - CLI
|
||||
{{< image
|
||||
src="images/ollama-docker-desktop.JPG"
|
||||
caption="Docker Desktop running Ollama" >}}
|
||||
|
||||
Now I can access the model via CLI. I happen to be using Docker Desktop on Windows, which allows me to access the container's shell via the UI. Here I can download different models and interact with them using the CLI. I demonstrate a few commands in the image, `ollama --version` to get the Ollama version, `ollama list` returns a list of the models installed (I already installed a few models to test). I can run a specific model with `ollama run <model name>` or `ollama run <model id>`. It works, incredible!
|
||||
|
||||
However, this is a poor way to interact with an LLM, and I can do better by running a web UI that will connect to Ollama API backend.
|
||||
|
||||
## OpenWebUI
|
||||
{{< image
|
||||
src="OpenWebUI"
|
||||
caption="images/openwebui.jpg" >}}
|
||||
|
||||
OpenWebUI is a web interface which I can use to interact with and manage my local models. I will also be running this in Docker.
|
||||
|
||||
### Docker Compose File
|
||||
Here is the Docker compose file I used to run OpenWebUI:
|
||||
```yaml
|
||||
services:
|
||||
openwebui:
|
||||
restart: unless-stopped
|
||||
image: ghcr.io/open-webui/open-webui:cuda
|
||||
#network_mode: host
|
||||
ports:
|
||||
- "3000:8080"
|
||||
volumes:
|
||||
- ./data:/app/backend/data
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: [gpu]
|
||||
```
|
||||
### Ollama API Connection
|
||||
Since I am running Ollama on port 1134 on the same machine, I configure the Ollama API connection to point to local machine:
|
||||
{{< image
|
||||
src="images/ollama-openwebui.JPG"
|
||||
caption="OpenWebUI ↔ Ollama Connection" >}}
|
||||
|
||||
## GPU Usage
|
||||
This screenshot shows that the model is indeed using my GPU to do all manner of linear algebra operations, etc., which eventually results in it generating some output text.
|
||||
|
||||
{{< image
|
||||
src="images/deepseek-r1-7b-GPU-usage.jpg"
|
||||
caption="GPU Usage - Deepseek r1:7b" >}}
|
||||
|
||||
## Realtime Usage
|
||||
{{< video
|
||||
src="videos/deepseek-r1-7b-GPU.mp4"
|
||||
width="100%"
|
||||
autoplay=true
|
||||
loop=true
|
||||
>}}
|
||||
|
||||
## Potential Uses
|
||||
I can integrate locally-hosted LLM APIs with new software projects. Who knows.
|
||||
@@ -0,0 +1,63 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2024-07-13T18:16:10Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "meat-emulsions"
|
||||
title = "🥩 Meat Emulsions - Gyros & Cheesesteaks"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
A meat emulsion is just a mixture of ground meat (in my case, mostly beef and lamb) combined with ice water, fat, and other mystery ingredients which I cover later, which form a homogeneous "emulsified" mixture. These are the same properties of many processed meat products like sausage, hot dogs, and salami. Technically, or in other contexts, an emulsion describes a stable mixture of two liquids that normally do not mix. Whether the meat mixtures I describe above ought to be considered emulsions is apparently controversial but I am not interested in this trivia. I will refer to the mixture as an emulsion.
|
||||
|
||||
## Why would I do such a thing?
|
||||
* **I like it**
|
||||
* This is perhaps the biggest and definitely the best reason to emulsify meats. It is bothersome to attempt to put to words where the satisfaction comes from, or why, so I will not do it. I simply find the process and results enjoyable.
|
||||
* **No Local Source**
|
||||
* I would happily buy if I could find it locally and the price was right.
|
||||
* **Cost**
|
||||
* The second best answer is the eye popping results of the cost calculations, for arguably a superior product. It is costlier by a factor of, according to my roughest calculations, something like 3 to produce an "equivalent" product to Philly steak (thinly sliced cuts of top round or rib-eye – ~8 and ~15 USD / lb., respectively).
|
||||
* **Uniformity of Taste & Texture**
|
||||
* The even distribution of seasonings, spices, fats, salts, and sugar throughout the meat mixture create unbeatable texture and flavor. Binders such as bread crumbs and milk powder help create a tender, fatty, juicy, smooth, rich piece of meat that nature alone could not hope to replicate in animal muscle.
|
||||
* **Handling, Storage & Other Conveniences**
|
||||
* I form the meat into cylinders (pressing into a 32 oz. deli container) and freeze them. Freezing gives them a functionally infinite shelf life. To prepare the meat, I need only slightly thaw the surface of the meat for ~5 minutes (freezer is ~14 F) before I am able to effortlessly slice off thin vertical strips. Afterward, the cylinder goes straight back into the freezer, completing the process which can safely be repeated infinitely. I pan fry or broil them for crispy edges and caramelized crusts.
|
||||
|
||||
## Resources
|
||||
*Home Production of Quality Meats and Sausages* – this book was a useful reference for typical usage amounts for various seasonings, spices, and other additions.
|
||||
|
||||
{{< image
|
||||
src="images/home-production-of-quality-meats-and-sausages.jpg"
|
||||
caption="Home Production of Quality Meats and Sausages" >}}
|
||||
|
||||
## The Process
|
||||
The process for any kind of gyro-like meat is the same.
|
||||
* Weigh out portion of meat
|
||||
* Using a food processor, shred enough ice for the recipe
|
||||
* Weigh and freeze the fat of choice (pork, beef, vegetable shortening, etc)
|
||||
* Weigh out specified amount of water ice and reserve in freezer
|
||||
* Mix together salt and dry seasonings
|
||||
* Add mixture to meat and mix thoroughly, by hand
|
||||
* Chill meat for 1 hr.
|
||||
* Add the meat to a food processor
|
||||
* Add bits of frozen fat to mixer
|
||||
* Mix
|
||||
* Periodically add shaved ice as the temperature climbs no higher than ~50 F
|
||||
|
||||
## Results
|
||||
|
||||
### Gyro Meat
|
||||
This is my favorite.
|
||||
{{< image
|
||||
src="images/meat-cone-deli-container.JPG"
|
||||
caption="Meat cylinder in deli container" >}}
|
||||
{{< image
|
||||
src="images/meat-cone.JPG"
|
||||
caption="Meat cone on cutting board" >}}
|
||||
{{< image
|
||||
src="images/gyro-meat-raw.JPG"
|
||||
caption="Uncooked gyro meat strips" >}}
|
||||
{{< image
|
||||
src="images/gyro-meat-cooked.JPG"
|
||||
caption="Cooked gyro meat strips" >}}
|
||||
|
||||
### "Philly" Cheese steak
|
||||
@@ -0,0 +1,18 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
date = 2025-02-22T13:40:00-06:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mixed-berry-muffins"
|
||||
title = "🫐 Mixed Berry Muffins"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Bailey made muffins. We only have one egg left. The recipe calls for two eggs, one egg was substituted with a combination of baking powder, water, and oil. I don't know what ratio she used, but it is a quick and dirty substitute. It will not matter.
|
||||
|
||||
{{< video
|
||||
src="videos/timelapse-bake-mixed-berry-muffins.webm"
|
||||
width="100%" >}}
|
||||
|
||||
{{< gallerynew name="mixed-berry-muffin-gallery">}}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
images:
|
||||
- src: "images/add-berries.JPG"
|
||||
alt: "Adding berries to a bowl"
|
||||
caption: "Adding berries"
|
||||
- src: "images/add-salt.JPG"
|
||||
alt: "Adding salt to mixture"
|
||||
caption: "Adding salt"
|
||||
- src: "images/cream-fat.JPG"
|
||||
alt: "Creaming fat in a bowl"
|
||||
caption: "Creaming fat"
|
||||
- src: "images/fill-tins.JPG"
|
||||
alt: "Filling muffin tins"
|
||||
caption: "Filling tins"
|
||||
- src: "images/measure-egg.JPG"
|
||||
alt: "Measuring an egg"
|
||||
caption: "Measuring egg"
|
||||
- src: "images/measure-flour.JPG"
|
||||
alt: "Measuring flour"
|
||||
caption: "Measuring flour"
|
||||
- src: "images/measure-milk.JPG"
|
||||
alt: "Measuring milk"
|
||||
caption: "Measuring milk"
|
||||
- src: "images/mix-dry-1.JPG"
|
||||
alt: "Mixing dry ingredients"
|
||||
caption: "Mixing dry ingredients"
|
||||
- src: "images/mix-dry-2.JPG"
|
||||
alt: "Stirring dry ingredients"
|
||||
caption: "Stirring dry mix"
|
||||
- src: "images/mix-in-berries.JPG"
|
||||
alt: "Mixing in berries"
|
||||
caption: "Mixing in berries"
|
||||
- src: "images/muffin-baked.JPG"
|
||||
alt: "Baked muffin on a surface"
|
||||
caption: "Muffin baked"
|
||||
- src: "images/muffin-extract-1.JPG"
|
||||
alt: "Removing muffin from tin"
|
||||
caption: "Taking muffin out"
|
||||
- src: "images/muffin-extract.JPG"
|
||||
alt: "Extracting muffin from pan"
|
||||
caption: "Removing muffin"
|
||||
- src: "images/muffin-in-tin.JPG"
|
||||
alt: "Muffin in baking tin"
|
||||
caption: "Muffin in tin"
|
||||
- src: "images/muffins-drying-rack.JPG"
|
||||
alt: "Muffins cooling on a rack"
|
||||
caption: "Muffins on rack"
|
||||
@@ -0,0 +1,113 @@
|
||||
+++
|
||||
categories = ["food"]
|
||||
tags = ["reverse-engineer","MOD"]
|
||||
date = 2024-07-19T15:27:08Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mod-pizza-cust"
|
||||
title = "🍕 MOD Pizza Crust"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
Once again, I document my attempt to replicate a delicious food item, equipped with a few sample pizzas, a spreadsheet, guesswork, and a few iterations. The target is the original thin crust pizza from MOD, a made-on-demand fast-casual food chain. Imagine Subway, except pizza. It is not important to know details of the business, except that on occasion it births from its ovens some of the most delightful pizza crust one can conceive.
|
||||
|
||||
This is a baking operation, not a cooking one. It is very much about the crust. The objective is to create a crust that is crispy and light. This is different than crunchy. I do not want the crust to be crunchy. I do not want the pizza to be dense. It needs to be thin and strong and crispy and light. With the methods and recipe described in this post I am able to consistently achieve each of the desirable characteristics described here.
|
||||
|
||||
## Nutrition
|
||||
The first place to start is the nutrition information for the food product. The MOD website provides nutrition info for the 11" thin crust. The reflow after I resize the viewport gives me this unfortunate little ugly table. It has what I want though.
|
||||
|
||||
{{< image
|
||||
src="images/mod_nutrition_table.PNG"
|
||||
caption="MOD nutrition table" >}}
|
||||
|
||||
## Ingredients
|
||||
Also supplied by the website is the ingredient list. These ingredients are very standard and contain no surprises. It is not exciting whatsoever, which leads me to believe the magic of the crust is not at all in the ingredients, but in the preparation.
|
||||
|
||||
{{< image
|
||||
src="images/mod_ingredient_list.PNG"
|
||||
caption="MOD ingredient list" >}}
|
||||
|
||||
## Recipe Development
|
||||
Below I outline the process of reverse-engineering the recipe by identifying the components of interest and extracting from the two tables of data some useful numbers with which to concoct a formula.
|
||||
* Fat
|
||||
* Right away we know exactly how much extra virgin olive oil we will use in the formula.
|
||||
* evoo is the only ingredient that appreciably contributes to the total fat. It is straightforward that there will be 6g of evoo per dough ball.
|
||||
* Salt
|
||||
* Similarly, but with additional wrinkle, we know the salt amount.
|
||||
* The amount of sodium (not salt, per se) can used to calculate the amount of salt, using the following formula:
|
||||
* (1310/1000)*2.54 = salt (g)
|
||||
* Flour
|
||||
* Once again, since we are given values for carbohydrates, and flour is composed of a known portion of carbohydrates (76% by weight), we can calculate the weight of flour by:
|
||||
* 88 / 0.76 = flour (g)
|
||||
* Water
|
||||
* I am not given anything about the water. The water content will need to be relatively low. This results in a dryer and therefore crispier crust.
|
||||
* I use ~50% water.
|
||||
* Yeast
|
||||
* The yeast content will depend on how I choose to prepare the dough. For example, if I want to cold ferment the dough for several days, which I do, I would use much less than if I wanted to ferment the dough only an hour.
|
||||
* I write this recipe to include instructions to cold-ferment dough balls for two to three days as it resulted in a crust with the most tiny little bubbles, which I have convinced myself quite unscientifically must be a sign of a crispier crust. Really though, the tiny bubbles look good to me.
|
||||
* Long fermentation times provide much time flexibility.
|
||||
|
||||
{{< image
|
||||
src="images/pizza-baking.JPG"
|
||||
caption="Pizza baking" >}}
|
||||
|
||||
{{< video
|
||||
src="videos/pizza-assembly.mp4"
|
||||
width="100%"
|
||||
autoplay=true
|
||||
loop=true
|
||||
>}}
|
||||
|
||||
|
||||
This table reflects the calculations and reasoning described above.
|
||||
|
||||
| Ingredient | Amount (g) |
|
||||
| ---------- | ---------- |
|
||||
| flour | 116.00 |
|
||||
| fat | 6.00 |
|
||||
| salt | 3.30 |
|
||||
| water | 58.00 |
|
||||
| yeast | 0.60 |
|
||||
|
||||
## Recipe
|
||||
While the recipe is indeed derived from the information above, it is certainly not the exact same one. Nor are the methods the same – MOD uses methods and equipment not available to me, such as a heated dough press that compresses and form the dough into a neat round disc, all while preserving air pockets. When heated, these air pockets will expand with hot vapor and eventually form a light and airy crust, even under the immense wet weight of my long list of toppings. Rolling out the dough by any other means is destructive to these air pockets and make for a less-crispy crust. Also available to MOD is a specially designed oven that reaches temperatures beyond 550 degrees F, and a cooking surface whose conductive properties are more finely tuned for such a pizza. My baking steel is highly conductive! Therefore, the goal is not necessarily to replicate the product through their processes and equipment, but instead to replicate the end product however it is possible.
|
||||
|
||||
### Formulation
|
||||
| Ingredient | Baker's % |
|
||||
| ------------------- | ----------- |
|
||||
| Flour, 11.6% | 100.00% |
|
||||
| Water | 50.00% |
|
||||
| Yeast, Instant, Dry | 0.50% |
|
||||
| Salt | 2.75% |
|
||||
| Oil | 5.00% |
|
||||
| Sugar | 1.00% |
|
||||
| **TOTAL** | **159.25%** |
|
||||
|
||||
### Process
|
||||
1. **Mix** the ingredients.
|
||||
2. **Shape** the dough into balls.
|
||||
3. **Cold ferment** the dough for **24–72 hours**.
|
||||
4. **Roll out** each dough ball to **11 inches** in diameter.
|
||||
5. **Preheat** the baking steel for **45 minutes** at **500°F**.
|
||||
6. **Lower** the oven temperature to **450°F**.
|
||||
7. **Bake** for **11 minutes**.
|
||||
8. **Transfer** the baked items to a cooling rack and cool for **5 minutes**.
|
||||
|
||||
|
||||
## Results
|
||||
Here are the results from a couple of pizzas I made using the dough recipe.
|
||||
|
||||
{{< image
|
||||
src="images/supreme-pizza.jpg"
|
||||
caption="Supreme pizza" >}}
|
||||
|
||||
{{< image
|
||||
src="images/crust-underside.JPG"
|
||||
caption="Crust leopard spotting" >}}
|
||||
|
||||
I have succeeded in producing a crispy, light, thin and strong crust.
|
||||
|
||||
## Variation
|
||||
To make an even crispier crust, I dried out the bottom of the crust. The dough is rolled out, inverted, dried overnight, flipped, topped, and finally baked.
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,176 @@
|
||||
+++
|
||||
categories = ["software"]
|
||||
tags = ["mssql"]
|
||||
date = 2024-10-01T14:20:28Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "mssql-db-config"
|
||||
title = "💽 Configuring Microsoft SQL Server Database"
|
||||
+++
|
||||
|
||||
I am using Microsoft's Blazor framework to create a Webassembly app that manages my recipes. I began building this web app using an in-memory database, which means the data does not persist after the program stops running. Each time I build, the app rebuilds the database with brand new data. This is annoying and unnecessary now that the general structure of the database has been established. I will transition the app to instead use a database on an on-premise server.
|
||||
|
||||
## Docker
|
||||
As with nearly every other service I host, I will run an instance of SQL Server in a Docker container. Microsoft provides a shell command that will get an instance of Linux-based SQL Server running.
|
||||
|
||||
```shell
|
||||
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD" \
|
||||
-p 1433:1433 --name sql1 --hostname sql1 \
|
||||
-d \
|
||||
mcr.microsoft.com/mssql/server:2022-latest
|
||||
```
|
||||
|
||||
I typically use Docker Compose to manage my services, so I will transform this command into a docker-compose.yml file. There are additional pieces of configuration missing here: timezone, product ID, and data persistence across container restarts. I add them in the final configuration file. Additionally, the System Administrator (user sa) password is stored in a .env file that lives in the root directory of the host.
|
||||
|
||||
|
||||
```shell
|
||||
nicholas@nas:/$ cd /hdd1/nicholas/docker/dockerfiles/
|
||||
nicholas@nas:/hdd1/nicholas/docker/dockerfiles/$ mkdir mssql && cd mssql
|
||||
nicholas@nas:/hdd1/nicholas/docker/dockerfiles/$ nano docker-compose.yml
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mssql:
|
||||
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||
container_name: mssql
|
||||
hostname: mssql
|
||||
environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- MSSQL_SA_PASSWORD=${MSSQL_SA_PASSWORD}
|
||||
- TZ=America/Chicago
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data:/var/opt/mssql/data
|
||||
- ./logs:/var/opt/mssql/log
|
||||
- ./secrets:/var/opt/mssql/secrets
|
||||
ports:
|
||||
- "1433:1433"
|
||||
```
|
||||
The container runs as user mssql, so I will need to give my container directory permissions to this user.
|
||||
|
||||
```shell
|
||||
sudo chown -R 10001:10001 ./mssql/
|
||||
sudo chmod -R 755 ./mssql/
|
||||
```
|
||||
|
||||
Next I start the container.
|
||||
|
||||
```shell
|
||||
sudo docker compose up -d
|
||||
[+] Running 2/4
|
||||
⠙ mssql [⣿⡀⣿] 168.3MB / 578.3MB Pulling 11.2s
|
||||
✔ 3d29464607d8 Pull complete 5.9s
|
||||
⠙ b14d3f02d050 Downloading [======> ]... 10.1s
|
||||
✔ c4ebe057087c Download complete 3.4sl
|
||||
```
|
||||
|
||||
The server is ready to accept connections now.
|
||||
|
||||
|
||||
## Microsoft SQL Server Management Studio
|
||||
|
||||
I will be using SSMS to connect to and configure the server.
|
||||
|
||||
The server name is the host name of my server, which is nas. I already configured the login credentials in the docker-compose.yml and .env files.
|
||||
|
||||
Here I create a database Formulation and the two tables: Ingredient and IngredientRelationship. Like I said, this could have been a spreadsheet.
|
||||
|
||||
|
||||
```sql
|
||||
CREATE DATABASE Formulation;
|
||||
|
||||
USE Formulation;
|
||||
|
||||
CREATE TABLE Ingredient (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||||
Name NVARCHAR(100) NOT NULL,
|
||||
Calories FLOAT NOT NULL,
|
||||
Protein FLOAT NOT NULL,
|
||||
Fat FLOAT NOT NULL,
|
||||
Carbohydrates FLOAT NOT NULL,
|
||||
Fiber FLOAT NOT NULL,
|
||||
Sugar FLOAT NOT NULL,
|
||||
Sodium FLOAT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IngredientRelationship (
|
||||
Id UNIQUEIDENTIFIER PRIMARY KEY,
|
||||
ParentId UNIQUEIDENTIFIER NOT NULL,
|
||||
ChildId UNIQUEIDENTIFIER NOT NULL,
|
||||
Proportion FLOAT NOT NULL,
|
||||
FOREIGN KEY (ParentId) REFERENCES Ingredient(Id),
|
||||
FOREIGN KEY (ChildId) REFERENCES Ingredient(Id)
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Mapping Entity Classes to Database Tables with Entity Framework
|
||||
|
||||
I am using Entity Framework in my web app, so I needed to define my database structure so that it mirrored the C# Entity Class I already defined. The Ingredient table in the database maps onto the C# Entity Class Ingredient.cs. The same is true for the IngredientRelationship table and IngredientRelationship.cs.
|
||||
|
||||
```c#
|
||||
namespace formulation.Common.Models
|
||||
{
|
||||
[Table("Ingredient")]
|
||||
public class Ingredient
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
[Required]
|
||||
[StringLength(100, ErrorMessage = "Name cannot be longer than 100 characters.")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public double Calories { get; set; } // in kcal
|
||||
public double Protein { get; set; } // in grams
|
||||
public double Fat { get; set; } // in grams
|
||||
public double Carbohydrates { get; set; } // in grams
|
||||
public double Fiber { get; set; } // in grams
|
||||
public double Sugar { get; set; } // in grams
|
||||
public double Sodium { get; set; } // in milligrams
|
||||
|
||||
public ICollection? Ingredients { get; set; }
|
||||
public ICollection? ParentIngredients { get; set; }
|
||||
public Ingredient()
|
||||
{
|
||||
Ingredients = new List();
|
||||
ParentIngredients = new List();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Create SQL Server User for App Connection
|
||||
|
||||
Now I need to create a dedicated SQL Server user for my app – one with only limited privileges. It is best practice to abide by the principle of least privilege, which states that a user should be granted the minimum level of access necessary to perform the required function. Therefore it is not recommended to use the sa user or any high-privilege accounts for an app connection.
|
||||
|
||||
```sql
|
||||
CREATE LOGIN Formulation WITH PASSWORD = '';
|
||||
USE Formulation;
|
||||
CREATE USER Formulation FOR LOGIN Formulation;
|
||||
EXEC sp_addrolemember 'db_datareader', 'Formulation'; -- Read permissions
|
||||
EXEC sp_addrolemember 'db_datawriter', 'Formulation'; -- Write permissions
|
||||
```
|
||||
|
||||
|
||||
## Connection Strings & Secrets
|
||||
|
||||
The next step is to configure a new Connected Service in Visual Studio. First I select the correct service dependency: SQL Server Database - On-premise
|
||||
|
||||
Next I enter the login credentials User name, Password, Server name, Database name which were configured in the steps above. I click test connection to verify that this is working as expected. It is successful.
|
||||
|
||||
Visual Studio automatically writes the connection string using this information. I save the connection strings in a local secrets file Secrets.json.
|
||||
|
||||
|
||||
## Using the App Connection User
|
||||
|
||||
Finally I can reference this connection string in Program.cs to connect to the SQL Server.
|
||||
|
||||
```c#
|
||||
builder.Services.AddDbContext(opt => opt.UseSqlServer(builder.Configuration.GetConnectionString("Formulation")));
|
||||
```
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,157 @@
|
||||
+++
|
||||
categories = ["technology","build"]
|
||||
date = 2021-09-09T14:53:39Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-build"
|
||||
title = "💾 Server Build"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
|
||||
## Notes
|
||||
I am in possession of an increasingly large heap of data and I need a place to put it. I need a server that will run services that help serve and organize the files. Cloud storage/server solutions are too expensive for my purposes.
|
||||
|
||||
|
||||
{{< image
|
||||
src="images/nas-internals.jpg"
|
||||
caption="NAS internal components" >}}
|
||||
|
||||
## Additional considerations in ascending order of plausibility / no real order.
|
||||
|
||||
* You use a USB stick to move files around and this process rapidly accelerates the rate at which your body experiences senescence.
|
||||
* Your favorite cloud storage service kicked you off for abusing their unlimited plan.
|
||||
* You cannot look yourself in the mirror lest you see a man in it you do not recognize, one that contrasts to the otherwise cool, confident and competent human being of 20 minutes ago, after you find yourself in a pinch forced to send files over Bluetooth in a last ditch effort to move one tiny, absolutely infuriatingly tiny but necessary configuration file to a device just inches away – a task so conceptually simple and obvious even your technologically mystified ancestors still laugh raucously at your impotence from the great beyond. You agonize over the tedium – again and again casting all the right spells, clicking all the right buttons, trying to stay calm to lower your dangerously spiking blood pressure. You avert your eyes in furious rebellion from the digital clock in the corner of the screen that seems to mock your focused efforts – you begin to regret the decision to allow the Windows task bar clock to display the seconds digits now – the ticking away a ceaseless reminder of your mortality and of your hopeless battle against God's will – to squeeze one moment of triumph – one small victory from your precious time on this Earth. You are Sisyphus, the file transfer your impossible burden. But what of your crime? What did you do to deserve this? No one and no thing will tell you and no error message reveals the nature of your failure. Not Sisyphus or Josef of The Trial, but some secret third, more hated thing. Your vision closes in around you try and you try and you will this cursed thing to be done! But the incantation is not quite right. You inflected where you should have introflected. Permissions issue. Firewalled. Timeout. DNS error. The transfer fails to your stuttering bewilderment and dismay in dozens of ways in the span of 12 minutes. In your fits of now blinding rage you are given no clues as to why – or rather – you cannot see the error messages, only that you have failed. You are resigned to accept your destiny, to forever struggle to move the file you have come to resent until the miser within you capitulates, finally releasing the funds necessary to buy your very own
|
||||
|
||||
## Network Attached Storage
|
||||
The solution to my data storage and retrieval woes is a `NAS`, which is a file storage server attached to my local network. As long as a device has access to the network, so too shall I have access to the data.
|
||||
|
||||
## Requirements
|
||||
* 3.5" Drive Bay capacity of 6.
|
||||
* Good things come in 3s and I will be mirroring each drive. Therefore 6.
|
||||
* Multi-core processor
|
||||
* Integrated GPU.
|
||||
* Low cooling requirements.
|
||||
* Low idle power consumption (>~10w).
|
||||
* 2.5G Intel NIC.
|
||||
* Enough RAM and room for expansion
|
||||
* Inexpensive
|
||||
* Parts widely available
|
||||
|
||||
## Build or Buy
|
||||
The first consideration is buying versus building. The distinction will probably be difficult to pin down since arguably even the machines I refer to as "pre-built" require some hardware installation and software configuration. But for the purposes here, the distinction between these two will be found mostly in the form and features of the machine. Where a pre-built dedicated NAS is typically an elegant yet stout, low profile bank of hard drives equipped with hot-swappable bays (I speak only of the non-enterprise, consumer-focused products), a DIY build can be of any form my heart desires so long as the limitations of the form factor are compatible with the requirements. The performance to price ratio will almost certainly weigh heavily in favor of the build path, but at the cost of that sweet, sweet NAS aesthetic in all of its flashing activity lights and hot-swap bay glory. In the end, this build is just a collection of computer hardware. I will be building this computer myself rather than buying one COTS style.
|
||||
|
||||
## Hardware
|
||||
The easiest part of a DIY NAS may be selecting the hardware. I use a PC hardware compatibility tool called PCPARTPICKER to ensure components that are compatible with all parts of the system.
|
||||
|
||||
| Type | Item |
|
||||
| ------------- | ---------------------------------------------------------------------------- |
|
||||
| CPU | Intel Pentium Gold G5400 3.7 GHz Dual-Core Processor |
|
||||
| Memory | Crucial 16 GB (2 x 8 GB) DDR4-2400 CL17 Memory |
|
||||
| Motherboard | ASRock H370M-ITX/ac Mini ITX LGA1151 Motherboard |
|
||||
| Case | Fractal Design Node 304 Mini ITX Tower Case |
|
||||
| Power Supply | Corsair CX (2017) 450 W 80+ Bronze Certified ATX Power Supply |
|
||||
| OS Disc | WD Blue SA510 SSD |
|
||||
| Miscellaneous | SATA Power Splitter Adapter Cable - M/F - 4x Serial ATA Power Cable Splitter |
|
||||
| | Zip Ties |
|
||||
|
||||
## Data Drives
|
||||
I can purchase my HDDs from my favorite retailer and received a big and fat juicy warranty and sleep well at night, reassured by the implications of my drives coming with something like a three year manufacturer warranty. OR I can consider the sneakier, riskier, more cost-effective play called "shucking".
|
||||
|
||||
## Shucking
|
||||
Shucking is the practice of purchasing external storage devices such as Western Digital EasyStore, or Seagate Expansion and removing the raw 3.5" hdd inside. The external drives are meant to function as portable, high capacity external storage devices that interface with computers over USB (as opposed to an internal drive via e.g. SATA), but this does not need to be the case. Huge discounts can and were achieved with certain models of HDDs/external storage devices, but in the face of (small) risks such as accidentally damaging the drive during shucking, potentially voiding certain warranties, and dealing with the general uncertainty of the specifications of the drive inside the device.
|
||||
|
||||
I have chosen to source my drives this way, and successfully shucked a total of 6 HDDs.
|
||||
|
||||
| Type | Purpose | Capacity | Device |
|
||||
| ---- | ---------------- | -------- | --------- |
|
||||
| hdd | data | 12 TB | /dev/sda |
|
||||
| hdd | data | 12 TB | /dev/sdb |
|
||||
| hdd | data | 12 TB | /dev/sdc |
|
||||
| hdd | data | 12 TB | /dev/sdd |
|
||||
| hdd | data | 8 TB | /dev/sde |
|
||||
| hdd | data | 8 TB | /dev/sdf |
|
||||
| ssd | operating system | 512 GB | /dev/nvme |
|
||||
|
||||
After installing all the hardware, the next step is to install an operating system on the OS drive. I prefer a Debian-based distribution, so I install the latest version. It makes more sense to install the OS before any of the disks, since I may be frequently cycling the power for troubleshooting purposes, and spinning up and down the discs this way will, however slightly, unnecessarily add wear-and-tear to the discs. My concern is probably on the verge of unhealthy paranoia.
|
||||
|
||||
## Disk Testing
|
||||
The process is variable depending on how rigorous the tests are designed to be. It is a matter of personal preference and risk tolerance. The purpose is to run the disks in such a way as to reveal their flaws should they have any – in other words, to check for bad blocks, sectors, and evaluate the general health through other means that are yet mysterious to me. Ideally I will run the tests before the drives are removed from their enclosures. There are methods that take anywhere from a few hours to days and even weeks. Two tools that will be useful to me in disk testing are badblocks and smartctl. I do not say much about my disk testing here. I use both to determine that all of my drives are free of bad blocks and are healthy.
|
||||
|
||||
```shell
|
||||
sudo apt install e2fsprogs -y && apt install smartmontools
|
||||
badblocks -b 4096 -wsv /dev/sda > badblocks_output.log
|
||||
```
|
||||
|
||||
### Filesystem - ZFS
|
||||
The next step is to decide on the filesystem. There are several options but ZFS makes the most sense to me. It is a purpose-built file system and volume manager designed for long-term data storage. Using ZFS allows data to be "mirrored" across different drives so that in the event of single drive failure, no data is lost and there is no downtime. The process of repairing the NAS is basically as simple as installing a new disk in place of the failed drive and use the ZFS replace command to re-silver the new disk. The system is configured as a single pool, which acts as a single file storage device – it can be mounted as a single directory. The pool consists of one zpool which consists of three vdevs , each of which are two-drive mirrors.
|
||||
|
||||
```
|
||||
sudo zpool status
|
||||
NAME STATE READ WRITE CKSUM
|
||||
hdd1 ONLINE 0 0 0
|
||||
mirror-0 ONLINE 0 0 0
|
||||
sda ONLINE 0 0 0
|
||||
sdb ONLINE 0 0 0
|
||||
mirror-1 ONLINE 0 0 0
|
||||
sdd ONLINE 0 0 0
|
||||
sdc ONLINE 0 0 0
|
||||
mirror-2 ONLINE 0 0 0
|
||||
sde ONLINE 0 0 0
|
||||
sdf ONLINE 0 0 0
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## Maintenance
|
||||
During the install process, ZFS may automatically create two cron jobs at /etc/cron.d/zfsutils-linux This is sort of self explanatory but these cron jobs will run once a month to perform TRIM and Scrub operations on the filesystem.
|
||||
|
||||
```shell
|
||||
cat /etc/cron.d/zfsutils-linux
|
||||
|
||||
# TRIM the first Sunday of every month.
|
||||
24 0 1-7 * * root if [ $(date +\%w) -eq 0 ] && [ -x /usr/lib/zfs-linux/trim ]; then /usr/lib/zfs-linux/trim; fi
|
||||
|
||||
# Scrub the second Sunday of every month.
|
||||
24 0 8-14 * * root if [ $(date +\%w) -eq 0 ] && [ -x /usr/lib/zfs-linux/scrub ]; then /usr/lib/zfs-linux/scrub; fi
|
||||
```
|
||||
|
||||
## Network Share - Samba
|
||||
This is where the computer turns into a NAS – where I will expose the mount point of the filesystem to the network, allowing authorized users to read and write to the filesystem. I use samba to do this. Here I install and configure the service.
|
||||
|
||||
```shell
|
||||
sudo apt update && sudo apt install samba -y
|
||||
sudo nano /etc/samba/smb.conf
|
||||
```
|
||||
|
||||
Appended the following lines to `smb.conf`
|
||||
|
||||
```
|
||||
[hdd1]
|
||||
comment = Some useful files
|
||||
read only = no
|
||||
guest ok = no
|
||||
path = /hdd1
|
||||
valid users = nicholas
|
||||
```
|
||||
This configuration enables one network share: `hdd1`. The path variables refer to the directory of the ZFS pool.
|
||||
|
||||
Samba requires its own password for each user to authenticate. Even if user nicholas already has password, he needs a separate Samba password.
|
||||
|
||||
```shell
|
||||
sudo smbpasswd -a nicholas
|
||||
```
|
||||
|
||||
The user also needs permissions
|
||||
|
||||
Now I restart Samba.
|
||||
|
||||
```shell
|
||||
sudo service smbd restart
|
||||
```
|
||||
|
||||
I should be able to access this share using the credentials of valid user which was defined in `/etc/samba/smb.conf`.
|
||||
|
||||
Done.
|
||||
@@ -0,0 +1,26 @@
|
||||
+++
|
||||
categories = ["technology","build"]
|
||||
date = 2024-11-15T17:43:02Z
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "server-build-refresh"
|
||||
title = "💾 Server Build Refresh"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
My NAS server only has 16 GB RAM. I upgrade this to 32 GB. upgrade the OS disk and move the components from a Fractal Design Node 304 desktop chassis to a Rosewill RSV-R4000U rack-mount chassis.
|
||||
|
||||
## Components
|
||||
| Type | Item |
|
||||
| ------------ | ------------------------------------------------------- |
|
||||
| CPU | Intel Pentium Gold G5400 3.7 GHz Dual-Core Processor |
|
||||
| Motherboard | ASRock H370M-ITX/ac Mini ITX LGA1151 Motherboard |
|
||||
| Memory | Crucial CP2K16G4DFRA32A 32GB Kit (2x16GB) DDR4 3200MHz |
|
||||
| Storage | Samsung SSD 970 EVO Plus 500GB |
|
||||
| Case | Rosewill RSV-R4000U 4U Server Chassis |
|
||||
| Power Supply | EVGA 450 BR 450 W 80+ Bronze Certified ATX Power Supply |
|
||||
|
||||
## Upgrading
|
||||
In a previous log entry I documented the build process of my storage server. At the time its only intended purpose was managing my file storage and hosting a media streaming service. Its purpose has expanded to a degree that it begs for additional RAM. I will double the RAM from 16GB to 32 GB, reaching the limitations of the motherboard. I will replace the WD Blue SA510 SSD storage drive, (which will replace a very slow mechanical drive in a laptop), with a Samsung 970 EVO Plus. Old and new components will be installed in a new chassis Rosewill RSV-R4000U and rack-mounted alongside the other computer.
|
||||
|
||||
{{< gallerynew name="nas-build-gallery" >}}
|
||||
@@ -0,0 +1,13 @@
|
||||
images:
|
||||
- src: "images/server-case-view.JPG"
|
||||
alt: "View of a server case."
|
||||
caption: "Server case overview."
|
||||
- src: "images/server-draw-open.JPG"
|
||||
alt: "Server drawer pulled open."
|
||||
caption: "Open server drawer."
|
||||
- src: "images/server-hdd-bays.JPG"
|
||||
alt: "Hard drive bays in a server."
|
||||
caption: "Server HDD bays."
|
||||
- src: "images/server-hdd-fans.JPG"
|
||||
alt: "Cooling fans near hard drives in a server."
|
||||
caption: "Server HDD cooling fans."
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3c656bcf276088261f2813eac6b6d0ad34be8e102e3cfb6696b803180d75c650
|
||||
size 8411676
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:1be37f12c244cc83fa79d48c786c0d645e2ff2bbed3511ac9f985d7569be0b19
|
||||
size 12627656
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8f99b7b607b51f640b01d4d6a7495994abf476b2eb4b211b10d02a87df6e0fe4
|
||||
size 10496525
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5663617799353daecb27ea1cbcd983b15e1bdb5fc29d66ee89783903dc203f16
|
||||
size 12165934
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:921df5d710c56fcbfd141e1ecaa496a45a8a1a79c863f4362f90b6a5189f5da5
|
||||
size 9422776
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f04279eb9759678bc25898eb9fcf9d3c861ca9d9b224a1e779e66b9cb7e0bfb0
|
||||
size 12796015
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2b448ffd621b45135229a34d20cd20f19a314c601a70b40b19db38ca472ad0ee
|
||||
size 11945114
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:78b39521c1b05539619ba1f36dea734842527ab7d8eb79c26e09b52fd211a994
|
||||
size 13314818
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5dd6485abfc0425050b26620687ab9d75752c3b98eb1b8ded02529d17cd02c0a
|
||||
size 12837110
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3cdd5b79e091bcf1ddce6ca49e9b5db726cd2b5582e4fccb13956b3cea32eee6
|
||||
size 10094598
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:62d1fdf5c70da34c7074a624e23b8534777cb7d904d9698a08cf0dab88d407cc
|
||||
size 12434399
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48f377d37d24b9a178b36a42d433516f7321475806629222240efde1b57dcab3
|
||||
size 9214058
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cc7d89211b461cd984ba9d3d5c50c26f5168d6adf8657398ccb235414af757ab
|
||||
size 3859808
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ce23c6165d019b996ebd4f2a5aa520c524b7113d6f1f037366257dd7f416e130
|
||||
size 8792237
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:882515afacf9be9326b98bf91128fefa2d61cc4a9ebb38ce29cd8d824b90c4d6
|
||||
size 8395666
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:efcf8f49f535f2744f65bf549717b34039a27e6e35eace7ac7d77a38b472a01f
|
||||
size 8542134
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:359868a8fbb9dd8a4769866120f395b250083d409e97ab7b07fddb95f7780c4e
|
||||
size 10427805
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2c3ed17720177b59ee1fabecf648bd756b3f9f1f9936c7a15a0ecb2579eb49ea
|
||||
size 8984123
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:4cb25d98a9f6d36a92520caee77eb1e1cef60102a3155873bd89bb1f58f88bd9
|
||||
size 8570195
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:cee02e4864be81c71e070dd8925ad13bc3861391e3ab0dcedce77421e52e26e0
|
||||
size 3475616
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:412ea4011511888237c8f2b3e8820915633d74424db75b8563408c5555d586e7
|
||||
size 3624368
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d99154b9b52fc68a246075ebdc202cf64cf60756ce4018b36cffa3ad70d623e4
|
||||
size 3157226
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:8ac22dd09381926683a627810425f8990228c39d75a75591af396bac170aec74
|
||||
size 7597749
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:77a6014dfdee701d24d4f71f167525756287652e3501c50b3ba0be4b40f0574e
|
||||
size 5505053
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:318b3f6d506530874b495c4bcb85a0c11015fb58fb8d5b3dd528669f1cda3bc8
|
||||
size 4160459
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:58f1650dae2f0d89d6277c22b367e46c98df9178a0cce9dbbca19e4345fdf441
|
||||
size 5911011
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d164eb220a631f81b80afea565b9e25769992100aa506b56beff8e52fb26adab
|
||||
size 4770424
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:eee0ce15d53ddb8644ee717bfb88c32a72755769b3041d8ec3e38bf6ac651d71
|
||||
size 7022910
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:92a49cd6cd0221ffa57b5e04e6bd65c838f0de9fd6de5f449581e65723218126
|
||||
size 3565111
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e64956786426f187f91bc4436a71c26c05e255b248a713340ca42b79635d5793
|
||||
size 4166669
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9781460a7bf8e87659d7fa1fbb86da45da8627210c0b08ad226f1206bbc64d37
|
||||
size 5499274
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5fed08378804c9a178c94f1b6e89bf08e4249fc853fb9bc4e3e536f5519c6965
|
||||
size 3515304
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2fcb37eefe0e940adadfbce71b6dd3f1d7a601326ab33f3ba1e64e76429aa28
|
||||
size 2572044
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:90822d728d4d471f32a8761b249a6b8592b9878ce732db53280f85558030d0f1
|
||||
size 3499334
|
||||
@@ -0,0 +1,26 @@
|
||||
+++
|
||||
categories = ["travel"]
|
||||
date = 2023-05-31T12:00:00-05:00
|
||||
description = ""
|
||||
draft = false
|
||||
slug = "pacific-northwest"
|
||||
title = "🌲 Pacific Northwest"
|
||||
author = "nicholas"
|
||||
+++
|
||||
|
||||
`05-02-2023 to 05-09-2023`
|
||||
|
||||
Bailey and I went to PNW coast for a few days.
|
||||
|
||||
- Seattle, Tacoma
|
||||
- Port Townsend
|
||||
- Wilderness Rim
|
||||
- Cannon Beach
|
||||
- Rockaway Beach
|
||||
- Cape Meares
|
||||
- La Push (à la *Twilight*)
|
||||
|
||||
|
||||
## Photo Gallery
|
||||
{{< gallerynew name="pnw-gallery" >}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user