From cbab9be8b3e35accfb854dd45070d978f39ac3b1 Mon Sep 17 00:00:00 2001 From: Patrick Kerwood Date: Sun, 5 May 2024 15:18:18 +0200 Subject: [PATCH] Initial commit cargo.toml update --- .editorconfig | 14 ++ .env-example | 7 + .github/workflows/build-n-release.yaml | 70 +++++++ .github/workflows/trigger-chart-build.yaml | 24 +++ .gitignore | 26 +++ Cargo.toml | 7 + Dockerfile | 50 +++++ Justfile | 34 ++++ README.md | 123 +++++++++++++ az_group_crd/Cargo.toml | 15 ++ az_group_crd/src/lib.rs | 44 +++++ az_group_manager/Cargo.toml | 29 +++ az_group_manager/src/controller/azure.rs | 171 ++++++++++++++++++ az_group_manager/src/controller/error.rs | 34 ++++ az_group_manager/src/controller/k8s.rs | 78 ++++++++ az_group_manager/src/controller/mod.rs | 4 + az_group_manager/src/controller/reconciler.rs | 68 +++++++ az_group_manager/src/main.rs | 130 +++++++++++++ az_group_manager_crd/Cargo.toml | 15 ++ az_group_manager_crd/src/lib.rs | 29 +++ chart/Chart.yaml | 5 + chart/crds/crds.yaml | 137 ++++++++++++++ chart/templates/cluster-role-binding.yaml | 12 ++ chart/templates/cluster-role.yaml | 21 +++ chart/templates/deployment.yaml | 58 ++++++ chart/templates/service-account.yaml | 4 + chart/values.yaml | 21 +++ rustfmt.toml | 2 + 28 files changed, 1232 insertions(+) create mode 100644 .editorconfig create mode 100644 .env-example create mode 100644 .github/workflows/build-n-release.yaml create mode 100644 .github/workflows/trigger-chart-build.yaml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 Justfile create mode 100644 README.md create mode 100644 az_group_crd/Cargo.toml create mode 100644 az_group_crd/src/lib.rs create mode 100644 az_group_manager/Cargo.toml create mode 100644 az_group_manager/src/controller/azure.rs create mode 100644 az_group_manager/src/controller/error.rs create mode 100644 az_group_manager/src/controller/k8s.rs create mode 100644 az_group_manager/src/controller/mod.rs create mode 100644 az_group_manager/src/controller/reconciler.rs create mode 100644 az_group_manager/src/main.rs create mode 100644 az_group_manager_crd/Cargo.toml create mode 100644 az_group_manager_crd/src/lib.rs create mode 100644 chart/Chart.yaml create mode 100644 chart/crds/crds.yaml create mode 100644 chart/templates/cluster-role-binding.yaml create mode 100644 chart/templates/cluster-role.yaml create mode 100644 chart/templates/deployment.yaml create mode 100644 chart/templates/service-account.yaml create mode 100644 chart/values.yaml create mode 100644 rustfmt.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..55e5a7f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,14 @@ +# top-most EditorConfig file +root = true + +[*.rs] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.md] +indent_style = space +indent_size = 2 +trim_trailing_whitespace = false diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..3477662 --- /dev/null +++ b/.env-example @@ -0,0 +1,7 @@ +# Copy/rename this file to ".env" and the Justfile will pick up the environment variables. + +AZURE_TENANT_ID= +AZURE_CLIENT_ID= +AZURE_CLIENT_SECRET= +RECONCILE_TIME=10 +RETRY_TIME=5 diff --git a/.github/workflows/build-n-release.yaml b/.github/workflows/build-n-release.yaml new file mode 100644 index 0000000..3851441 --- /dev/null +++ b/.github/workflows/build-n-release.yaml @@ -0,0 +1,70 @@ +name: Build and Release + +on: + push: + branches: + - "!*" + tags: + - "v*" + +env: + CARGO_TERM_COLOR: always + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + rustfmt: + name: Rustfmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + - run: rustup component add rustfmt + - run: cargo fmt -- --check + + build-and-push-image: + name: Build OCI image and push + needs: rustfmt + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + + create-release: + name: Create Release + needs: build-and-push-image + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: ncipollo/release-action@v1 + with: + body: "Image name: `ghcr.io/${{ github.repository }}:${{ github.ref_name }}`" + generateReleaseNotes: true + makeLatest: true diff --git a/.github/workflows/trigger-chart-build.yaml b/.github/workflows/trigger-chart-build.yaml new file mode 100644 index 0000000..2f61d74 --- /dev/null +++ b/.github/workflows/trigger-chart-build.yaml @@ -0,0 +1,24 @@ +name: Chart Releaser + +on: + push: + branches: + - main + paths: + - "chart/Chart.yaml" + +jobs: + trigger-workflow: + name: Trigger Chart Workflow + runs-on: ubuntu-latest + + steps: + - uses: convictional/trigger-workflow-and-wait@v1.6.5 + with: + owner: kerwood + repo: helm-charts + github_token: ${{ secrets.WORKFLOW_PAT }} + workflow_file_name: chart-releaser.yaml + propagate_failure: true + trigger_workflow: true + wait_workflow: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4efe11d --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Created by https://www.toptal.com/developers/gitignore/api/dotenv,rust +# Edit at https://www.toptal.com/developers/gitignore?templates=dotenv,rust + +### kubectl ### +kubeconfig + +### dotenv ### +.env + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# End of https://www.toptal.com/developers/gitignore/api/dotenv,rust diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6e15c92 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "2" +members = [ + "az_group_crd", + "az_group_manager_crd", + "az_group_manager", +] diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d4982a3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,50 @@ +################################################################################### +## Builder +################################################################################### +FROM rust:alpine3.20 AS builder + +ENV OPENSSL_STATIC=1 + +# RUN rustup target add x86_64-unknown-linux-musl +# RUN apt update && apt install -y musl-tools musl-dev pkg-config libssl-dev upx make +RUN apk update && apk add --no-cache musl-dev openssl-dev openssl-libs-static upx +RUN update-ca-certificates + +# Create appuser +ENV USER=rust +ENV UID=1001 + +RUN adduser \ + --disabled-password \ + --gecos "" \ + --home "/" \ + --shell "/sbin/nologin" \ + --no-create-home \ + --uid "${UID}" \ + "${USER}" + + +WORKDIR /workdir + +COPY ./ . + +RUN cargo build --target x86_64-unknown-linux-musl --release +RUN upx --best --lzma target/x86_64-unknown-linux-musl/release/az-group-manager + +################################################################################### +## Final image +################################################################################### +FROM scratch + +WORKDIR / + +# Copy from builder. +COPY --from=builder /etc/passwd /etc/passwd +COPY --from=builder /etc/group /etc/group +COPY --from=builder /etc/ssl/certs /etc/ssl/certs +COPY --from=builder /workdir/target/x86_64-unknown-linux-musl/release/az-group-manager/ / + +# Use an unprivileged user. +USER 1001:1001 + +ENTRYPOINT ["./az-group-manager"] diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..9540811 --- /dev/null +++ b/Justfile @@ -0,0 +1,34 @@ +set dotenv-load +cluster_name := "azure-group-controller" + +[private] +default: + @just --list + +# Create a .env file to use for local development. +create-dot-env: + cat .env-example >> .env + +# Cargo run with needed parameters +run: + cargo run serve -t $AZURE_TENANT_ID -i $AZURE_CLIENT_ID -s $AZURE_CLIENT_SECRET -b $RECONCILE_TIME -r $RETRY_TIME + +# Bring up the Kind cluster +cluster-up: + kind create cluster --name {{cluster_name}} --image kindest/node:v1.30.2 + sleep "10" + kubectl wait --namespace kube-system --for=condition=ready pod --selector="tier=control-plane" --timeout=180s + +# Bring down the Kind cluster +cluster-down: + kind delete cluster --name {{cluster_name}} + -rm ./kubeconfig + +# Apply AzureGroup and AzureGroupManager CRDs +crds-apply: + cargo run -p az-group-manager -- print-crd | kubectl apply -f - + +# Delete AzureGroup and AzureGroupManager CRDs +crds-delete: + kubectl delete crd azuregroupmanagers.kerwood.github.com azuregroups.kerwood.github.com + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e49744 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# Azure Group Controller + +[![forthebadge made-with-rust](http://ForTheBadge.com/images/badges/made-with-rust.svg)](https://www.rust-lang.org/) + +A Kubernetes controller that creates a `AzureGroup` resource with a list of members and some basic information on the group. +The controller will continuously reonconsile the `AzureGroup` resource. + +## Prerequisites + +For deploying: + +- Helm, +- Kubectl, + +For developing: + +- Kind, +- Rust, +- Just, + +## Install + +Start be creating a default App Registration in your Azure tenant. Don't chose any Platform, just give it a name. +Add the `GroupMember.Read.All` and `User.ReadBasic.All` Application Permissions to the App Registration and create a new client secret. +If the maximun expiration date of years is not enough for you, use the `az` cli to set as many years as you want. + +Create a Kubernetes secret in your cluster containing the Azure tenant, client ID and secret. + +``` +kubectl create secret generic az-group-manager \ + --from-literal=tenant-id= \ + --from-literal=client-id= \ + --from-literal=client-secret= +``` + +Add the Helm repository and update. + +``` +helm repo add kerwood https://kerwood.github.io/helm-charts +helm repo update +``` + +Install the controller. + +``` +helm install az-group-manager kerwood/az-group-manager --namespace +``` + +## How to use it + +Create a `AzureGroupManager` resources with the UUID of a Azure Group in the spec. + +```yaml +apiVersion: kerwood.github.com/v1 +kind: AzureGroupManager +metadata: + name: my-azure-group-name-can-be-anything +spec: + groupUid: 00b9c3c9-09d1-4e58-bd89-ec3ebcfb47e6 +``` + +The controller will create a child resource with the group information. + +```yaml +apiVersion: kerwood.github.com/v1 +kind: AzureGroup +metadata: + name: planet-express + ... +spec: + count: 2 + description: Best delivery boys in the business + displayName: Planet Express + id: 00b9c3c9-09d1-4e58-bd89-ec3ebcfb47e6 + mail: planet.express@futurama.com + members: + - displayName: Bender Rodriguez + id: 814a8ea1-c2d5-47ca-8f80-c838646536c3 + mail: bender.rodriguez@futurama.com + - displayName: Philip J. Fry + id: 631fd65d-7d72-4969-a6e5-56ad6062485f + mail: philip.j.fry@futurama.com +``` + +# Command Line Interface + +``` +Usage: az-group-manager [OPTIONS] + +Commands: + serve Start the service + print-crd Print the Custom Resource Definition for AzureGroup + +Options: + --structured-logs Logs will be output as JSON. [env: STRUCTURED_LOGS=] + -l, --log-level Log Level. [env: LOG_LEVEL=] [default: info] [possible values: trace, debug, info, warn, error] + -h, --help Print help + -V, --version Print version + +Author: Patrick Kerwood +``` + +``` +Usage: az-group-manager serve [OPTIONS] --az-tenant-id --az-client-id --az-client-secret + +Options: + -t, --az-tenant-id + Azure Tenant ID. [env: AZ_TENANT_ID=] + -i, --az-client-id + Azure App Registration Client ID. [env: AZ_CLIENT_ID=] + -s, --az-client-secret + Azure App Registration Client Secret. [env: AZ_CLIENT_SECRET=] + --structured-logs + Logs will be output as JSON. [env: STRUCTURED_LOGS=] + -b, --reconcile-time + Seconds between each reconciliation. [env: RECONCILE_TIME=] [default: 300] + -l, --log-level + Log Level. [env: LOG_LEVEL=] [default: info] [possible values: trace, debug, info, warn, error] + -r, --retry-time + Seconds between each retry if reconciliation fails. [env: RETRY_TIME=] [default: 10] + -h, --help + Print help +``` diff --git a/az_group_crd/Cargo.toml b/az_group_crd/Cargo.toml new file mode 100644 index 0000000..6d0b468 --- /dev/null +++ b/az_group_crd/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "az_group_crd" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +kube = { version = "0.92", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.22", features = ["v1_30"] } +serde = { version = "1.0.204", features = ["rc"] } +serde_derive = "1.0.204" +serde_json = "1.0.120" +serde_yaml = "0.9.34" +schemars = "0.8.21" diff --git a/az_group_crd/src/lib.rs b/az_group_crd/src/lib.rs new file mode 100644 index 0000000..5a86be4 --- /dev/null +++ b/az_group_crd/src/lib.rs @@ -0,0 +1,44 @@ +#[macro_use] +extern crate serde_derive; +use kube::{CustomResource, CustomResourceExt}; +use schemars::JsonSchema; + +#[derive(CustomResource, Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[kube( + group = "kerwood.github.com", + version = "v1", + kind = "AzureGroup", + namespaced, + printcolumn = r#"{"name":"COUNT", "type":"string", "jsonPath":".spec.count"}"#, + printcolumn = r#"{"name":"ID", "type":"string", "jsonPath":".spec.id"}"#, + printcolumn = r#"{"name":"LAST UPDATE", "type":"string", "jsonPath":".status.lastUpdate"}"#, + printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"# +)] +#[kube(status = "AzureGroupStatus")] +#[serde(rename_all = "camelCase")] +pub struct AzureGroupSpec { + pub id: String, + pub members: Vec, + pub count: usize, + pub display_name: String, + pub description: Option, + pub mail: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AzureGroupStatus { + pub last_update: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema, Eq, PartialEq, Hash)] +#[serde(rename_all = "camelCase")] +pub struct Member { + pub id: String, + pub display_name: String, + pub mail: String, +} + +pub fn print_crd() -> Result { + serde_yaml::to_string(&AzureGroup::crd()) +} diff --git a/az_group_manager/Cargo.toml b/az_group_manager/Cargo.toml new file mode 100644 index 0000000..55ed262 --- /dev/null +++ b/az_group_manager/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "az-group-manager" +version = "0.1.0" +edition = "2021" +description = "Fetch members from Azure AD groups and populate the result to an AzureGroup resource." +authors = ["Patrick Kerwood "] + +[dependencies] +az_group_crd = { path = "../az_group_crd/"} +az_group_manager_crd = { path = "../az_group_manager_crd/"} +clap = { version = "4.5", features = ["derive", "env"] } +kube = { version = "0.92", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.22", features = ["v1_30"] } +futures = "0.3.30" +thiserror = "1.0.61" +chrono = "0.4.38" +tracing = "0.1.40" +tracing-subscriber = { version = "0.3.18", features = ["json"] } +azure_core = "0.20.0" +azure_identity = { version = "0.20.0", features = ["enable_reqwest_rustls"] } +reqwest = { version = "0.12.5", features = ["json"] } +tokio = { version = "1.38.0", features = ["macros", "rt-multi-thread"] } +url = "2.5.2" +serde_json = "1.0.120" +serde_yaml = "0.9.34" +serde = { version = "1.0.204", features = ["rc"] } +serde_derive = "1.0.204" +schemars = "0.8.21" +slug = "0.1.5" diff --git a/az_group_manager/src/controller/azure.rs b/az_group_manager/src/controller/azure.rs new file mode 100644 index 0000000..afa74fe --- /dev/null +++ b/az_group_manager/src/controller/azure.rs @@ -0,0 +1,171 @@ +use super::error::Error as CrateError; +use super::error::Result; +use super::reconciler::Args; +use az_group_crd::{AzureGroupSpec, Member}; +use azure_identity::client_credentials_flow; +use slug::slugify; +use tracing::{debug, error}; +use url::Url; + +// The response object from the Graph API. +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GroupInfoResponse { + pub display_name: Option, + pub description: Option, + pub mail: Option, +} + +// The response object from the Graph API. +#[derive(Debug, Deserialize, Clone)] +pub struct GroupResponse { + #[serde(rename = "value")] + pub members: Vec, + pub id: Option, + pub display_name: Option, + pub description: Option, + pub mail: Option, +} + +impl GroupResponse { + pub fn slug_display_name(&self) -> Option { + self.display_name.as_ref()?; + self.display_name.clone().map(slugify) + } +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct GroupResponseMember { + pub id: String, + pub display_name: String, + pub mail: Option, +} + +impl TryFrom for Member { + type Error = CrateError; + fn try_from(value: GroupResponseMember) -> Result { + if value.mail.is_none() { + return Err(CrateError::IntoMemberFailed(format!( + "propery 'mail' is missing on {}", + value.display_name + ))); + } + + Ok(Self { + id: value.id, + display_name: value.display_name, + mail: value.mail.unwrap(), + }) + } +} + +impl TryFrom for AzureGroupSpec { + type Error = CrateError; + fn try_from(group_response: GroupResponse) -> Result { + if group_response.id.is_none() { + return Err(CrateError::IntoAzureGroupSpecFailed( + "field 'id' is None.".to_string(), + )); + } + + if group_response.display_name.is_none() { + let message = format!( + "field 'display_name' is None on group: {}", + group_response.id.unwrap() + ); + return Err(CrateError::IntoAzureGroupSpecFailed(message)); + } + + let (members_res, fails): (Vec<_>, Vec<_>) = group_response + .members + .into_iter() + .map(|x| x.try_into()) + .partition(Result::is_ok); + + fails.into_iter().for_each(|x| error!("{}", x.unwrap_err())); + let members: Vec = members_res.into_iter().map(Result::unwrap).collect(); + + let result = Self { + id: group_response.id.unwrap(), + count: members.len(), + members, + description: group_response.description, + mail: group_response.mail, + display_name: group_response.display_name.unwrap(), + }; + + Ok(result) + } +} + +pub async fn get_azure_group(args: &Args, group_uuid: &String) -> Result { + let http_client = azure_core::new_http_client(); + + // This will give you the final token to use in authorization. + let token = client_credentials_flow::perform( + http_client.clone(), + &args.az_client_id, + &args.az_client_secret, + &["https://graph.microsoft.com/.default"], + &args.az_tenant_id, + ) + .await?; + + // Get all member is the group + let members = get_members(group_uuid, token.access_token().secret()).await?; + + // Get basic information about the group. + let group_info = get_group_info(group_uuid, token.access_token().secret()).await?; + + let group_response = GroupResponse { + id: Some(group_uuid.to_string()), + display_name: group_info.display_name, + mail: group_info.mail, + description: group_info.description, + ..members + }; + + Ok(group_response) +} + +async fn get_members(group_uuid: &str, token: &str) -> Result { + let members_url = Url::parse(&format!( + "https://graph.microsoft.com/v1.0/groups/{}/members", + group_uuid + ))?; + let members_resp = reqwest::Client::new() + .get(members_url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + debug!( + "https://graph.microsoft.com/v1.0/groups/{}/members :: {:?}", + group_uuid, members_resp + ); + + Ok(members_resp) +} + +async fn get_group_info(group_uuid: &str, token: &str) -> Result { + let group_info_url = Url::parse(&format!("https://graph.microsoft.com/v1.0/groups/{}", group_uuid))?; + let group_info_resp = reqwest::Client::new() + .get(group_info_url) + .header("Authorization", format!("Bearer {}", token)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + debug!( + "https://graph.microsoft.com/v1.0/groups/{} :: {:?}", + group_uuid, group_info_resp + ); + + Ok(group_info_resp) +} diff --git a/az_group_manager/src/controller/error.rs b/az_group_manager/src/controller/error.rs new file mode 100644 index 0000000..bf39726 --- /dev/null +++ b/az_group_manager/src/controller/error.rs @@ -0,0 +1,34 @@ +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("reqwest:Error [ {0} ]")] + Reqwest(#[from] reqwest::Error), + + #[error("azure_core::Error [ {0} ]")] + AzureCore(#[from] azure_core::Error), + + #[error("url::ParseError [ {0} ]")] + URLParse(#[from] url::ParseError), + + #[error("kube::Error [ {0} ]")] + KubeRS(#[from] kube::Error), + + #[error("Namespace is missing from AzureGroup resource {0}.")] + NamespaceMissing(String), + + #[error("could not convert GroupResponseMember into Member. {0}")] + IntoMemberFailed(String), + + #[error("could not convert GroupResponse into AzureGroupSpec. {0}")] + IntoAzureGroupSpecFailed(String), + + #[error("MissingObjectKey: {0}")] + MissingObjectKey(&'static str), + + #[error("GroupResponse is missing display_name propety: {0}")] + MissingDisplayName(String), + + #[error("AzureGroupCreationFailed: {0}")] + AzureGroupCreationFailed(#[source] kube::Error), +} + +pub type Result = std::result::Result; diff --git a/az_group_manager/src/controller/k8s.rs b/az_group_manager/src/controller/k8s.rs new file mode 100644 index 0000000..471787f --- /dev/null +++ b/az_group_manager/src/controller/k8s.rs @@ -0,0 +1,78 @@ +use super::azure::GroupResponse; +use super::error::{Error, Result}; +use az_group_crd::{AzureGroup, AzureGroupSpec, AzureGroupStatus}; +use az_group_manager_crd::AzureGroupManager; +use chrono::prelude::*; +use kube::api::{Patch, PatchParams}; +use kube::{api::ObjectMeta, Api, Resource}; +use tracing::debug; + +/// Creates an AzureGroup resource in Kubernetes using the provided `GroupResponse` and `AzureGroupManager`. +/// +/// Arguments +/// * `g_response` - A reference to the `GroupResponse` containing the specifications for the Azure Group to be created. +/// * `manager` - A reference to the `AzureGroupManager` for getting owner reference and namepace. +/// * `k8s_client` - A reference to the `kube::Client` used to interact with the Kubernetes API server. +/// +/// Returns a `Result` containing the Kubernetes resource. +pub async fn create_azure_group_resource( + g_response: &GroupResponse, + manager: &AzureGroupManager, + k8s_client: &kube::Client, +) -> Result { + let azure_spec: AzureGroupSpec = g_response.clone().try_into()?; + let owner_ref = manager.controller_owner_ref(&()).unwrap(); + let namespace = manager + .metadata + .namespace + .as_ref() + .ok_or_else(|| Error::MissingObjectKey(".metadata.namespace"))?; + + let az_group_api = Api::::namespaced(k8s_client.clone(), namespace); + + let group_slug_name = g_response + .slug_display_name() + .ok_or_else(|| Error::MissingDisplayName(azure_spec.id.to_string()))?; + + let az_group = AzureGroup { + metadata: ObjectMeta { + name: Some(group_slug_name.clone()), + owner_references: Some(vec![owner_ref.clone()]), + ..ObjectMeta::default() + }, + spec: azure_spec, + status: Some(AzureGroupStatus { + last_update: Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), + }), + }; + + let result = patch_azure_group(&group_slug_name, &az_group_api, az_group).await?; + + Ok(result) +} + +async fn patch_azure_group( + obj_name: &str, + az_group_api: &Api, + az_group: AzureGroup, +) -> Result { + let result = az_group_api + .patch( + obj_name, + &PatchParams::apply("azure-group-controller").force(), + &Patch::Apply(&az_group), + ) + .await + .map_err(Error::AzureGroupCreationFailed)?; + + debug!("patched kubernetes object {} :: {:?}", obj_name, result); + + az_group_api + .patch_status(obj_name, &PatchParams::default(), &Patch::Merge(&az_group)) + .await + .map_err(Error::AzureGroupCreationFailed)?; + + debug!("patched kubernetes status object {}", obj_name); + + Ok(result) +} diff --git a/az_group_manager/src/controller/mod.rs b/az_group_manager/src/controller/mod.rs new file mode 100644 index 0000000..f66059a --- /dev/null +++ b/az_group_manager/src/controller/mod.rs @@ -0,0 +1,4 @@ +pub mod azure; +pub mod error; +pub mod k8s; +pub mod reconciler; diff --git a/az_group_manager/src/controller/reconciler.rs b/az_group_manager/src/controller/reconciler.rs new file mode 100644 index 0000000..23a38ee --- /dev/null +++ b/az_group_manager/src/controller/reconciler.rs @@ -0,0 +1,68 @@ +use super::azure; +use super::error::{Error, Result}; +use super::k8s; +use az_group_crd::AzureGroup; +use az_group_manager_crd::AzureGroupManager; +use futures::StreamExt; +use kube::{ + runtime::{ + controller::{Action, Controller}, + watcher, + }, + Api, Client, ResourceExt, +}; +use std::{sync::Arc, time::Duration}; +use tracing::{debug, error}; + +#[derive(Debug, Clone)] +pub struct Args { + pub az_tenant_id: String, + pub az_client_id: String, + pub az_client_secret: String, + pub reconcile_time: u64, + pub retry_time: u64, +} + +#[derive(Clone)] +struct ReconcileContext { + cli_args: Args, + k8s_client: Client, +} + +pub async fn run(cli_args: Args) -> Result<(), kube::Error> { + let k8s_client = Client::try_default().await?; + let azure_groups_manager_api = Api::::all(k8s_client.clone()); + let azure_groups_api = Api::::all(k8s_client.clone()); + let ctx = ReconcileContext { cli_args, k8s_client }; + + Controller::new(azure_groups_manager_api.clone(), Default::default()) + .owns(azure_groups_api, watcher::Config::default()) + .shutdown_on_signal() + .run(reconcile, error_policy, Arc::new(ctx)) + .for_each(|_| futures::future::ready(())) + .await; + + Ok(()) +} + +async fn reconcile(manager: Arc, ctx: Arc) -> Result { + debug!("running reconcile for manager object: {}", manager.name_any()); + + match azure::get_azure_group(&ctx.cli_args, &manager.spec.group_uid).await { + Ok(group_response) => { + k8s::create_azure_group_resource(&group_response, &manager, &ctx.k8s_client).await? + } + Err(err) => return Err(err), + }; + + Ok(Action::requeue(Duration::from_secs(ctx.cli_args.reconcile_time))) +} + +fn error_policy(_object: Arc, err: &Error, ctx: Arc) -> Action { + error!( + "{}, retrying in {} seconds.", + err.to_string(), + ctx.cli_args.retry_time + ); + Action::requeue(Duration::from_secs(ctx.cli_args.retry_time)) +} diff --git a/az_group_manager/src/main.rs b/az_group_manager/src/main.rs new file mode 100644 index 0000000..8132841 --- /dev/null +++ b/az_group_manager/src/main.rs @@ -0,0 +1,130 @@ +#[macro_use] +extern crate serde_derive; +mod controller; +use clap::{Parser, Subcommand, ValueEnum}; +use controller::reconciler; +use std::error::Error; + +use tracing::{info, Level}; + +#[derive(Parser, Debug)] +#[command( + name = "az-group-fetcher", + about, + version, + after_help = "Author: Patrick Kerwood ", + disable_help_subcommand = true +)] +struct Opt { + #[command(subcommand)] + pub command: SubCommand, + + #[arg(long, env, help = "Logs will be output as JSON.", global = true)] + structured_logs: bool, + + #[arg( + short = 'l', + long, + env, + default_value = "info", + help = "Log Level.", + global = true + )] + log_level: LogLevel, +} + +#[derive(Subcommand, Debug)] +enum SubCommand { + /// Start the service. + Serve { + #[arg(short = 't', long, env, help = "Azure Tenant ID.")] + az_tenant_id: String, + + #[arg(short = 'i', long, env, help = "Azure App Registration Client ID.")] + az_client_id: String, + + #[arg(short = 's', long, env, help = "Azure App Registration Client Secret.")] + az_client_secret: String, + + #[arg( + short = 'b', + long, + env, + default_value = "300", + help = "Seconds between each reconciliation." + )] + reconcile_time: String, + + #[arg( + short = 'r', + long, + env, + default_value = "10", + help = "Seconds between each retry if reconciliation fails." + )] + retry_time: String, + }, + + /// Print the Custom Resource Definition for AzureGroup. + PrintCrd {}, +} + +#[derive(Debug, Clone, ValueEnum)] +enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +impl From for Level { + fn from(log_level: LogLevel) -> Self { + match log_level { + LogLevel::Trace => Level::TRACE, + LogLevel::Debug => Level::DEBUG, + LogLevel::Info => Level::INFO, + LogLevel::Warn => Level::WARN, + LogLevel::Error => Level::ERROR, + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let opt = Opt::parse(); + + let log_level: Level = opt.log_level.into(); + let subscriber_builder = tracing_subscriber::fmt().with_max_level(log_level); + + match opt.structured_logs { + true => subscriber_builder.json().init(), + false => subscriber_builder.init(), + }; + + match opt.command { + SubCommand::Serve { + az_client_id, + az_client_secret, + az_tenant_id, + reconcile_time, + retry_time, + } => { + info!("Running application!"); + _ = reconciler::run(reconciler::Args { + az_tenant_id, + az_client_id, + az_client_secret, + reconcile_time: reconcile_time.parse()?, + retry_time: retry_time.parse()?, + }) + .await; + } + SubCommand::PrintCrd {} => { + let manager_crd = az_group_manager_crd::print_crd().unwrap(); + let group_crd = az_group_crd::print_crd().unwrap(); + println!("{}\n---\n{}", manager_crd, group_crd); + } + } + Ok(()) +} diff --git a/az_group_manager_crd/Cargo.toml b/az_group_manager_crd/Cargo.toml new file mode 100644 index 0000000..c33fb8f --- /dev/null +++ b/az_group_manager_crd/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "az_group_manager_crd" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +kube = { version = "0.92", features = ["runtime", "derive"] } +k8s-openapi = { version = "0.22", features = ["v1_30"] } +serde = { version = "1.0.204", features = ["rc"] } +serde_derive = "1.0.204" +serde_json = "1.0.120" +serde_yaml = "0.9.34" +schemars = "0.8.21" diff --git a/az_group_manager_crd/src/lib.rs b/az_group_manager_crd/src/lib.rs new file mode 100644 index 0000000..eb50092 --- /dev/null +++ b/az_group_manager_crd/src/lib.rs @@ -0,0 +1,29 @@ +#[macro_use] +extern crate serde_derive; +use kube::{CustomResource, CustomResourceExt}; +use schemars::JsonSchema; + +#[derive(CustomResource, Debug, Serialize, Deserialize, Clone, JsonSchema)] +#[kube( + group = "kerwood.github.com", + version = "v1", + kind = "AzureGroupManager", + namespaced, + printcolumn = r#"{"name":"ID", "type":"string", "jsonPath":".spec.groupUid"}"#, + printcolumn = r#"{"name":"Age", "type":"date", "jsonPath":".metadata.creationTimestamp"}"# +)] +#[kube(status = "AzureGroupManagerStatus")] +#[serde(rename_all = "camelCase")] +pub struct AzureGroupManagerSpec { + pub group_uid: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct AzureGroupManagerStatus { + pub last_update: String, +} + +pub fn print_crd() -> Result { + serde_yaml::to_string(&AzureGroupManager::crd()) +} diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..f652fb9 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,5 @@ +apiVersion: v2 +name: azure-group-controller +description: Reconciles group information from Azure to Kubernetes AzureGroup resources. +version: 1.0.0 +appVersion: v0.1.0 diff --git a/chart/crds/crds.yaml b/chart/crds/crds.yaml new file mode 100644 index 0000000..2d9deac --- /dev/null +++ b/chart/crds/crds.yaml @@ -0,0 +1,137 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: azuregroupmanagers.kerwood.github.com +spec: + group: kerwood.github.com + names: + categories: [] + kind: AzureGroupManager + plural: azuregroupmanagers + shortNames: [] + singular: azuregroupmanager + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.groupUid + name: ID + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for AzureGroupManagerSpec via `CustomResource` + properties: + spec: + properties: + groupUid: + type: string + required: + - groupUid + type: object + status: + nullable: true + properties: + lastUpdate: + type: string + required: + - lastUpdate + type: object + required: + - spec + title: AzureGroupManager + type: object + served: true + storage: true + subresources: + status: {} + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: azuregroups.kerwood.github.com +spec: + group: kerwood.github.com + names: + categories: [] + kind: AzureGroup + plural: azuregroups + shortNames: [] + singular: azuregroup + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.count + name: COUNT + type: string + - jsonPath: .spec.id + name: ID + type: string + - jsonPath: .status.lastUpdate + name: LAST UPDATE + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: Auto-generated derived type for AzureGroupSpec via `CustomResource` + properties: + spec: + properties: + count: + format: uint + minimum: 0.0 + type: integer + description: + nullable: true + type: string + displayName: + type: string + id: + type: string + mail: + nullable: true + type: string + members: + items: + properties: + displayName: + type: string + id: + type: string + mail: + type: string + required: + - displayName + - id + - mail + type: object + type: array + required: + - count + - displayName + - id + - members + type: object + status: + nullable: true + properties: + lastUpdate: + type: string + required: + - lastUpdate + type: object + required: + - spec + title: AzureGroup + type: object + served: true + storage: true + subresources: + status: {} + diff --git a/chart/templates/cluster-role-binding.yaml b/chart/templates/cluster-role-binding.yaml new file mode 100644 index 0000000..3216ce0 --- /dev/null +++ b/chart/templates/cluster-role-binding.yaml @@ -0,0 +1,12 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: az-group-manager-controller +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: az-group-manager-controller +subjects: + - kind: ServiceAccount + name: az-group-manager + namespace: {{ .Release.Namespace }} diff --git a/chart/templates/cluster-role.yaml b/chart/templates/cluster-role.yaml new file mode 100644 index 0000000..5fd945e --- /dev/null +++ b/chart/templates/cluster-role.yaml @@ -0,0 +1,21 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: az-group-manager-controller +rules: + - apiGroups: + - kerwood.github.com + resources: + - azuregroupmanagers + verbs: + - get + - watch + - list + + - apiGroups: + - kerwood.github.com + resources: + - azuregroupmanagers + - azuregroups/status + verbs: + - "*" diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..f0a6392 --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: az-group-manager-controller +spec: + replicas: 1 + selector: + matchLabels: + app: az-group-manager-controller + template: + metadata: + labels: + app: az-group-manager-controller + spec: + serviceAccountName: az-group-manager + containers: + - name: controller + image: "{{ .Values.image }}:{{ .Values.tag }}" + args: ['serve'] + env: + - name: AZ_TENANT_ID + valueFrom: + secretKeyRef: + name: az-group-manager + key: tenant-id + - name: AZ_CLIENT_ID + valueFrom: + secretKeyRef: + name: az-group-manager + key: client-id + - name: AZ_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: az-group-manager + key: client-secret + - name: STRUCTURED_LOGS + value: {{ .Values.structuredLogs | quote }} + - name: RECONCILE_TIME + value: {{ .Values.reconcileTime | quote }} + - name: RETRY_TIME + value: {{ .Values.retryTime | quote }} + - name: LOG_LEVEL + value: {{ .Values.logLevel }} + securityContext: + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + privileged: false + readOnlyRootFilesystem: true + resources: + requests: + cpu: 200m + memory: 100Mi + securityContext: + seccompProfile: + type: RuntimeDefault diff --git a/chart/templates/service-account.yaml b/chart/templates/service-account.yaml new file mode 100644 index 0000000..c4a0258 --- /dev/null +++ b/chart/templates/service-account.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: az-group-manager diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..32ae155 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,21 @@ +image: ghcr.io/kerwood/az-group-manager-controller +tag: v0.1.0 +# +# Controller log level, possible values: trace, debug, info, warn, error. +logLevel: info + +# Enable structured logs. +structuredLogs: true + +# Time between each reconciliation loop in seconds. +reconcileTime: 300 + +# Retry time in seconds if reconciliation fails. +retryTime: 60 + +# The name of the Kubernetes secret containing the following keys. +# - tenant-id +# - client-id +# - client-secret +azureConfig: + secretName: az-group-manager diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..56a87d0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 110 +edition = "2018"