diff --git a/.opencode/skills/release/SKILL.md b/.opencode/skills/release/SKILL.md new file mode 100644 index 0000000..7160f28 --- /dev/null +++ b/.opencode/skills/release/SKILL.md @@ -0,0 +1,190 @@ +--- +name: release +description: Guide the release process for robotlb, including version bumping, Helm chart sync, changelog updates, and creating release branches. +--- + +## Purpose + +Provide step-by-step instructions for releasing a new version of robotlb, ensuring proper versioning, Helm chart sync, changelog updates, and release branch management. + +## When to use + +Use this skill when asked to: +- Create a new release +- Bump the version +- Prepare a release PR +- Update the changelog for a release + +## Prerequisites + +Before starting a release: +1. Ensure you are on the `main` branch +2. Ensure the working tree is clean (no uncommitted changes) +3. Ensure local main is up to date with origin/main + +## Version Decision Guide + +Use Semantic Versioning (MAJOR.MINOR.PATCH). Determine the bump type by analyzing commits since the last release: + +### Major Version (X.0.0) + +Bump MAJOR when: +- Breaking changes to the CLI interface (removed/renamed commands or flags) +- Breaking changes to configuration format +- Breaking changes to public APIs +- Commit message contains `BREAKING CHANGE:` or `!` (e.g., `feat!: ...`) + +### Minor Version (0.X.0) + +Bump MINOR when: +- New features added (`feat:` commits) +- New CLI commands or flags +- New configuration options +- Backward-compatible enhancements + +### Patch Version (0.0.X) + +Bump PATCH when: +- Bug fixes (`fix:` commits) +- Documentation updates (`docs:` commits) +- Internal refactoring (`refactor:` commits) +- Performance improvements without API changes +- Dependency updates + +### Decision Process + +1. Run: `git log v$(sed -n 's/^version = "\(.*\)"/\1/p' ./Cargo.toml | head -n1)..HEAD --oneline` +2. Check commit messages for: + - `!` or `BREAKING CHANGE:` -> MAJOR + - `feat:` -> MINOR + - `fix:`, `docs:`, `refactor:`, etc. -> PATCH +3. If multiple types, use the highest precedence (MAJOR > MINOR > PATCH) + +## Release Process + +### Step 1: Verify Clean State + +Ensure you're on main with no uncommitted changes and up to date with origin: + +```bash +git checkout main +git pull origin main +git status # Should show "nothing to commit, working tree clean" +``` + +If there are local commits not on origin/main, they must be merged first. + +### Step 2: Determine Version + +1. Get current version: + ```bash + cat Cargo.toml | grep '^version =' + ``` + +2. Review commits since last release: + ```bash + git log v..HEAD --oneline + ``` + +3. Decide on MAJOR, MINOR, or PATCH bump based on the Version Decision Guide above. + +### Step 3: Create Release Branch + +Create a branch named `release/v{NEW_VERSION}`: + +```bash +git checkout -b release/v +``` + +Example: `git checkout -b release/v0.3.0` + +### Step 4: Update Version in Cargo.toml + +Edit `Cargo.toml` and update the version field: + +```toml +version = "" +``` + +The version is in the `[package]` section. + +### Step 5: Update Cargo.lock + +After changing Cargo.toml, update the lock file: + +```bash +cargo update -p robotlb +``` + +### Step 6: Update Helm Chart Metadata + +Keep the Helm chart version and appVersion in sync with the application release: + +```bash +VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' ./Cargo.toml | head -n1) +sed -i -E "s/^version: .*/version: ${VERSION}/" ./helm/Chart.yaml +sed -i -E "s/^appVersion: .*/appVersion: \"${VERSION}\"/" ./helm/Chart.yaml +``` + +### Step 7: Update Changelog + +Generate the changelog using git-cliff: + +```bash +VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' ./Cargo.toml | head -n1) +git cliff -t ${VERSION} -u -p CHANGELOG.md +``` + +The changelog will be automatically updated with commits since the last release, grouped by type (Added, Fixed, Documentation, etc.). + +### Step 8: Commit Changes + +Stage and commit all changes: + +```bash +git add . +VERSION=$(sed -n 's/^version = "\(.*\)"/\1/p' ./Cargo.toml | head -n1) +git commit -m "release: Version $VERSION" +``` + +### Step 9: Push Branch and Create PR + +Push the release branch: + +```bash +git push -u origin release/v +``` + +Create a pull request to merge into main. + +### Step 10: After Merge + +After the PR is merged to main: +1. Create and push a tag: `git tag v && git push origin v` +2. The CI workflow automatically builds release artifacts and creates a GitHub Release + +## Quick Reference + +| Step | Command | +|------|---------| +| Check current version | `grep '^version =' Cargo.toml` | +| View recent commits | `git log v..HEAD --oneline` | +| Create branch | `git checkout -b release/v` | +| Update lock file | `cargo update -p robotlb` | +| Update Helm chart | `sed -i -E "s/^version: .*/version: ${VERSION}/" ./helm/Chart.yaml` | +| Update changelog | `git cliff -t ${VERSION} -u -p CHANGELOG.md` | +| Commit | `git commit -m "release: Version "` | + +## Checklist + +- [ ] On main branch, clean working tree +- [ ] Pulled latest from origin/main +- [ ] Determined version bump type (MAJOR/MINOR/PATCH) +- [ ] Created release branch `release/v` +- [ ] Updated version in Cargo.toml +- [ ] Ran `cargo update -p robotlb` +- [ ] Updated Helm chart version and appVersion in `./helm/Chart.yaml` +- [ ] Ran `git cliff -t ${VERSION} -u -p CHANGELOG.md` +- [ ] Committed with message `release: Version ` +- [ ] Pushed branch and created PR +- [ ] After merge: created tag `v` diff --git a/README.md b/README.md index 881243e..ee36dfe 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ After the chart is installed, you should be able to create `LoadBalancer` servic ### High availability -`robotlb` supports safe multi-replica deployment via Kubernetes Lease-based leader election. -Run at least 2 replicas and use pod anti-affinity so only one pod is active leader while the -other is standby. +`robotlb` supports safe multi-replica deployment via Kubernetes Lease-based leader election. Run at +least 2 replicas and use pod anti-affinity so only one pod is active leader while the other is +standby. Example Helm values: @@ -140,6 +140,9 @@ metadata: # Requests specific IP address for the load balancer in the private network. If not specified, # a random one is given. This parameter does nothing in case if network is not specified. robotlb/lb-private-ip: "10.10.10.10" + # Whether to keep the public interface enabled. Set this to false for an internal-only load + # balancer. In that case, the private IP is announced in the Service status. + robotlb/lb-public-interface: "false" # Node selector for the loadbalancer. This is only required if ROBOTLB_DYNAMIC_NODE_SELECTOR # is set to false. If not specified then, all nodes will be selected as LB targets by default. # This property helps you filter out nodes. @@ -164,9 +167,9 @@ metadata: # Location of the load balancer. This expects the code of one of Hetzner's available locations. robotlb/lb-location: "hel1" # Balancing algorithm. Can be either - # * least-connection + # * least-connections # * round-robin - robotlb/lb-algorithm: "least-connection" + robotlb/lb-algorithm: "least-connections" # Type of balancer. robotlb/balancer-type: "lb11" spec: diff --git a/src/consts.rs b/src/consts.rs index 7155e1d..59b819b 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -23,6 +23,8 @@ pub const LB_PROXY_MODE_ANNOTATION: &str = "robotlb/lb-proxy-mode"; pub const LB_NETWORK_ANNOTATION: &str = "robotlb/lb-network"; /// Annotation key for private IP mode. pub const LB_PRIVATE_IP_ANNOTATION: &str = "robotlb/lb-private-ip"; +/// Annotation key for enabling the public interface. +pub const LB_PUBLIC_INTERFACE_ANNOTATION: &str = "robotlb/lb-public-interface"; /// Annotation key for load balancer location. pub const LB_LOCATION_ANNOTATION: &str = "robotlb/lb-location"; diff --git a/src/controller/mod.rs b/src/controller/mod.rs index ed12b8f..cf61efd 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -158,7 +158,12 @@ pub async fn reconcile_load_balancer( let hcloud_lb = lb.reconcile().await?; - let ingress = build_ingress(&hcloud_lb, context.config.ipv6_ingress, lb.proxy_mode); + let ingress = build_ingress( + &hcloud_lb, + context.config.ipv6_ingress, + lb.proxy_mode, + lb.public_interface, + ); patch_ingress_status(&svc, &context, ingress).await?; Ok(Action::requeue(Duration::from_secs(SUCCESS_REQUEUE_SECS))) diff --git a/src/controller/status.rs b/src/controller/status.rs index 324a836..39f293b 100644 --- a/src/controller/status.rs +++ b/src/controller/status.rs @@ -15,10 +15,23 @@ pub fn build_ingress( hcloud_lb: &hcloud::models::LoadBalancer, enable_ipv6: bool, proxy_mode: bool, + public_interface: bool, ) -> Vec { let ip_mode = if proxy_mode { "Proxy" } else { "VIP" }; let mut ingress = vec![]; + if !public_interface { + for private_net in &hcloud_lb.private_net { + if let Some(ip) = &private_net.ip { + ingress.push(json!({ + "ip": ip, + "ipMode": ip_mode + })); + } + } + return ingress; + } + let dns_ipv4 = hcloud_lb.public_net.ipv4.dns_ptr.clone().flatten(); let ipv4 = hcloud_lb.public_net.ipv4.ip.clone().flatten(); let dns_ipv6 = hcloud_lb.public_net.ipv6.dns_ptr.clone().flatten(); @@ -76,3 +89,63 @@ pub async fn patch_ingress_status( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_load_balancer() -> hcloud::models::LoadBalancer { + hcloud::models::LoadBalancer { + public_net: Box::new(hcloud::models::LoadBalancerPublicNet { + ipv4: Box::new(hcloud::models::LoadBalancerPublicNetIpv4 { + ip: Some(Some("203.0.113.10".to_string())), + dns_ptr: Some(Some("public.example.com".to_string())), + }), + ipv6: Box::new(hcloud::models::LoadBalancerPublicNetIpv6 { + ip: Some(Some("2001:db8::10".to_string())), + dns_ptr: Some(Some("public-v6.example.com".to_string())), + }), + ..Default::default() + }), + private_net: vec![hcloud::models::LoadBalancerPrivateNet { + ip: Some("10.10.0.5".to_string()), + ..Default::default() + }], + ..Default::default() + } + } + + #[test] + fn build_ingress_uses_public_addresses_when_enabled() { + let ingress = build_ingress(&test_load_balancer(), true, false, true); + + assert_eq!( + ingress, + vec![ + json!({ + "ip": "203.0.113.10", + "dns": "public.example.com", + "ipMode": "VIP" + }), + json!({ + "ip": "2001:db8::10", + "dns": "public-v6.example.com", + "ipMode": "VIP" + }) + ] + ); + } + + #[test] + fn build_ingress_uses_private_address_when_public_interface_is_disabled() { + let ingress = build_ingress(&test_load_balancer(), true, false, false); + + assert_eq!( + ingress, + vec![json!({ + "ip": "10.10.0.5", + "ipMode": "VIP" + })] + ); + } +} diff --git a/src/lb/api.rs b/src/lb/api.rs index 4674210..9288894 100644 --- a/src/lb/api.rs +++ b/src/lb/api.rs @@ -215,6 +215,7 @@ pub async fn create_load_balancer( location: &str, balancer_type: &str, algorithm: LoadBalancerAlgorithm, + public_interface: bool, ) -> RobotLBResult { let response = hcloud::apis::load_balancers_api::create_load_balancer( hcloud_config, @@ -227,7 +228,7 @@ pub async fn create_load_balancer( name: name.to_string(), network: None, network_zone: None, - public_interface: Some(true), + public_interface: Some(public_interface), services: Some(vec![]), targets: Some(vec![]), }, diff --git a/src/lb/config.rs b/src/lb/config.rs index a50521b..50a80b9 100644 --- a/src/lb/config.rs +++ b/src/lb/config.rs @@ -72,9 +72,21 @@ pub fn parse_load_balancer_config( .get(consts::LB_PRIVATE_IP_ANNOTATION) .cloned(); + let public_interface = + parse_annotation(svc, consts::LB_PUBLIC_INTERFACE_ANNOTATION)?.unwrap_or(true); + + if !public_interface && network_name.is_none() { + return Err(RobotLBError::Generic(format!( + "{} requires {} to be set when disabled", + consts::LB_PUBLIC_INTERFACE_ANNOTATION, + consts::LB_NETWORK_ANNOTATION + ))); + } + Ok(ParsedLoadBalancerConfig { name, private_ip, + public_interface, balancer_type, check_interval, timeout, @@ -177,6 +189,7 @@ mod tests { assert_eq!(parsed.balancer_type, "lb11"); assert_eq!(parsed.network_name.as_deref(), Some("default-net")); assert!(!parsed.proxy_mode); + assert!(parsed.public_interface); } #[test] @@ -194,6 +207,7 @@ mod tests { (consts::LB_ALGORITHM_ANNOTATION, "round-robin"), (consts::LB_NETWORK_ANNOTATION, "private-net"), (consts::LB_PRIVATE_IP_ANNOTATION, "10.10.0.5"), + (consts::LB_PUBLIC_INTERFACE_ANNOTATION, "false"), ]); let parsed = parse_load_balancer_config(&svc, &config).expect("parse should succeed"); @@ -207,12 +221,24 @@ mod tests { assert_eq!(parsed.network_name.as_deref(), Some("private-net")); assert_eq!(parsed.private_ip.as_deref(), Some("10.10.0.5")); assert!(parsed.proxy_mode); + assert!(!parsed.public_interface); assert_eq!( parsed.algorithm.r#type, hcloud::models::load_balancer_algorithm::Type::RoundRobin ); } + #[test] + fn rejects_private_only_load_balancer_without_network() { + let mut config = base_config(); + config.default_network = None; + let svc = service_with_annotations([(consts::LB_PUBLIC_INTERFACE_ANNOTATION, "false")]); + + let result = parse_load_balancer_config(&svc, &config); + + assert!(matches!(result, Err(RobotLBError::Generic(_)))); + } + #[test] fn returns_error_for_invalid_algorithm_annotation() { let config = base_config(); diff --git a/src/lb/mod.rs b/src/lb/mod.rs index d0d4a2b..4ac6dad 100644 --- a/src/lb/mod.rs +++ b/src/lb/mod.rs @@ -43,6 +43,8 @@ pub struct LoadBalancer { pub targets: Vec, /// Optional private IP for the load balancer. pub private_ip: Option, + /// Whether the public interface should be enabled. + pub public_interface: bool, /// Health check interval in seconds. pub check_interval: i32, @@ -84,6 +86,7 @@ impl LoadBalancer { Ok(Self { name: parsed.name, private_ip: parsed.private_ip, + public_interface: parsed.public_interface, balancer_type: parsed.balancer_type, check_interval: parsed.check_interval, timeout: parsed.timeout, @@ -529,6 +532,7 @@ impl LoadBalancer { &self.location, &self.balancer_type, self.algorithm.clone(), + self.public_interface, ) .await } @@ -708,6 +712,7 @@ mod tests { services: HashMap::new(), targets: vec![], private_ip: None, + public_interface: true, check_interval: 15, timeout: 10, retries: 3, diff --git a/src/lb/types.rs b/src/lb/types.rs index 877f79a..53d228c 100644 --- a/src/lb/types.rs +++ b/src/lb/types.rs @@ -42,6 +42,7 @@ impl From for LoadBalancerAlgorithm { pub(crate) struct ParsedLoadBalancerConfig { pub(super) name: String, pub(super) private_ip: Option, + pub(super) public_interface: bool, pub(super) balancer_type: String, pub(super) check_interval: i32, pub(super) timeout: i32, diff --git a/tutorial.md b/tutorial.md index 8ddc550..5dd651b 100644 --- a/tutorial.md +++ b/tutorial.md @@ -470,6 +470,7 @@ controller: type: LoadBalancer annotations: robotlb/lb-network: "" + robotlb/lb-public-interface: "false" robotlb/balancer-type: "lb11" robotlb/lb-algorithm: "least-connections" externalTrafficPolicy: "Local"