From 25d7be004cfe52caa5603501cda15e1983ed0b36 Mon Sep 17 00:00:00 2001 From: "Dustin C. Hatch" Date: Sat, 1 Oct 2022 10:34:03 -0500 Subject: [PATCH] Begin EC2 instance state event handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle of ephemeral Kubernetes worker nodes is driven by events emitted by Amazon EventBridge and delivered via Amazon Simple Notification Service. These events trigger the *dynk8s* provisioner to take the appropriate action based on the state of an EC2 instance. In order to add a node to the cluster using `kubeadm`, a "bootstrap token" needs to be created. When manually adding a node, this would be done e.g. using `kubeadm token create`. Since bootstrap tokens are just a special type of Secret, they can be easily created programmatically as well. When a new EC2 instance enters the "running" state, the provisioner creates a new bootstrap token and associates it with the instance by storing the instance ID in a label in the Secret resource's metadata. The initial implementation of the event handler is rather naïve. It generates a token for every instance, though some instances may not be intended to be used as Kubernetes workers. Ideally, the provisioner would only allocate tokens for instances matching some configurable criteria, such as AWS tags. Further, a token is allocated every time the instance enters the running state, even if a token already exists or is not needed. --- Cargo.lock | 428 +++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 7 + src/events.rs | 29 +++ src/k8s.rs | 244 +++++++++++++++++++++++++ src/main.rs | 2 + src/model/events.rs | 46 +++++ src/model/mod.rs | 1 + src/sns/mod.rs | 18 +- 8 files changed, 766 insertions(+), 9 deletions(-) create mode 100644 src/events.rs create mode 100644 src/k8s.rs create mode 100644 src/model/events.rs diff --git a/Cargo.lock b/Cargo.lock index 41e9158..4667651 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,24 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4f55bd91a0978cbfd91c457a164bab8b4001c833b7f323132c0a4e1922dd44e" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -59,7 +77,7 @@ dependencies = [ "num-traits", "rusticata-macros", "thiserror", - "time", + "time 0.3.14", ] [[package]] @@ -206,6 +224,22 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "serde", + "time 0.1.44", + "wasm-bindgen", + "winapi", +] + [[package]] name = "cipher" version = "0.3.0" @@ -235,7 +269,7 @@ dependencies = [ "rand", "sha2", "subtle", - "time", + "time 0.3.14", "version_check", ] @@ -368,6 +402,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.3" @@ -384,7 +439,12 @@ name = "dynk8s-provisioner" version = "0.1.0" dependencies = [ "base64", + "chrono", + "k8s-openapi", + "kube", "log", + "rand", + "regex", "reqwest", "rocket", "rsa", @@ -471,6 +531,7 @@ checksum = "7f21eda599937fba36daeb58a22e8f5cee2d14c4a17b5b7739c7c8e5e3b8230c" dependencies = [ "futures-channel", "futures-core", + "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -493,12 +554,34 @@ version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e5aa3de05362c3fb88de6531e6296e85cde7739cccad4b9dfeeb7f6ebce56bf" +[[package]] +name = "futures-executor" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ff63c23854bee61b6e9cd331d523909f238fc7636290b96826e9cfa5faa00ab" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-io" version = "0.3.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbf4d2a7a308fd4578637c0b17c7e1c7ba127b8f6ba00b29f717e9655d85eb68" +[[package]] +name = "futures-macro" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42cd15d1c7456c04dbdf7e88bcd69760d74f3a798d6444e16974b505b0e62f17" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.24" @@ -520,6 +603,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -559,7 +643,7 @@ checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -652,6 +736,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfe8eed0a9285ef776bb792479ea3834e8b94e13d615c2f66d03dd50a435a29" + [[package]] name = "httparse" version = "1.8.0" @@ -688,6 +778,36 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-openssl" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ee5d7a8f718585d1c3c61dfde28ef5b0bb14734b4db13f5ada856cdc6c612b" +dependencies = [ + "http", + "hyper", + "linked_hash_set", + "once_cell", + "openssl", + "openssl-sys", + "parking_lot", + "tokio", + "tokio-openssl", + "tower-layer", +] + +[[package]] +name = "hyper-timeout" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb958482e8c7be4bc3cf272a766a2b0bf1a6755e7a6ae777f017a31d11b13b1" +dependencies = [ + "hyper", + "pin-project-lite", + "tokio", + "tokio-io-timeout", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -701,6 +821,19 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd911b35d940d2bd0bea0f9100068e5b97b51a1cbe13d13382f132e0365257a0" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "js-sys", + "wasm-bindgen", + "winapi", +] + [[package]] name = "idna" version = "0.2.3" @@ -759,6 +892,96 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonpath_lib" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa63191d68230cccb81c5aa23abd53ed64d83337cacbb25a7b8c7979523774f" +dependencies = [ + "log", + "serde", + "serde_json", +] + +[[package]] +name = "k8s-openapi" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9455388f4977de4d0934efa9f7d36296295537d774574113a20f6082de03da" +dependencies = [ + "base64", + "bytes", + "chrono", + "http", + "percent-encoding", + "serde", + "serde-value", + "serde_json", + "url", +] + +[[package]] +name = "kube" +version = "0.75.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb19108692aeafebb108fd0a1c381c06ac4c03859652599420975165e939b8a" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "0.75.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e1a80ecd1b1438a2fc004549e155d47250b9e01fbfcf4cfbe9c8b56a085593" +dependencies = [ + "base64", + "bytes", + "chrono", + "dirs-next", + "either", + "futures", + "http", + "http-body", + "hyper", + "hyper-openssl", + "hyper-timeout", + "jsonpath_lib", + "k8s-openapi", + "kube-core", + "openssl", + "pem", + "pin-project", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "0.75.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4d780f2bb048eeef64a4c6b2582d26a0fe19e30b4d3cc9e081616e1779c5d47" +dependencies = [ + "chrono", + "form_urlencoded", + "http", + "k8s-openapi", + "once_cell", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -780,6 +1003,21 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "292a948cd991e376cf75541fe5b97a1081d713c618b4f1b9500f8844e49eb565" +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linked_hash_set" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47186c6da4d81ca383c7c47c1bfc80f4b95f4720514d860a5407aaf4233f9588" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lock_api" version = "0.4.8" @@ -855,7 +1093,7 @@ checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys", ] @@ -1051,6 +1289,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "ordered-float" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7940cf2ca942593318d07fcf2596cdca60a85c9e7fab408a5e21a4f9dcd40d87" +dependencies = [ + "num-traits", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -1097,6 +1344,15 @@ dependencies = [ "syn", ] +[[package]] +name = "pem" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c64931a1a212348ec4f3b4362585eca7159d0d09cbdf4a7f74f02173596fd4" +dependencies = [ + "base64", +] + [[package]] name = "pem-rfc7468" version = "0.3.1" @@ -1112,6 +1368,26 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pin-project" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad29a609b6bcd67fee905812e544992d216af9d755757c05ed2d0e15a74c6ecc" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "069bdb1e05adc7a8990dce9cc75370895fbe4e3d58b9b73bf1aee56359344a55" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -1240,6 +1516,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b" +dependencies = [ + "getrandom", + "redox_syscall", + "thiserror", +] + [[package]] name = "ref-cast" version = "1.0.9" @@ -1266,6 +1553,8 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c4eb3267174b8c6c2f654116623910a0fef09c4753f8dd83db29c48a0df988b" dependencies = [ + "aho-corasick", + "memchr", "regex-syntax", ] @@ -1360,7 +1649,7 @@ dependencies = [ "serde_json", "state", "tempfile", - "time", + "time 0.3.14", "tokio", "tokio-stream", "tokio-util", @@ -1407,7 +1696,7 @@ dependencies = [ "smallvec", "stable-pattern", "state", - "time", + "time 0.3.14", "tokio", "uncased", ] @@ -1475,6 +1764,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "secrecy" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bd1c54ea06cfd2f6b63219704de0b9b4f72dcc2b8fdef820be6cd799780e91e" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.7.0" @@ -1507,6 +1806,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_derive" version = "1.0.144" @@ -1524,6 +1833,7 @@ version = "1.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" dependencies = [ + "indexmap", "itoa", "ryu", "serde", @@ -1541,6 +1851,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578a7433b776b56a35785ed5ce9a7e777ac0598aac5a6dd1b4b18a307c7fc71b" +dependencies = [ + "indexmap", + "ryu", + "serde", + "yaml-rust", +] + [[package]] name = "sha1" version = "0.10.2" @@ -1718,6 +2040,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.14" @@ -1771,6 +2104,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "tokio-io-timeout" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b74022ada614a1b4834de765f9bb43877f910cc8ce4be40e89042c9223a8bf" +dependencies = [ + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-macros" version = "1.8.0" @@ -1792,6 +2135,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-openssl" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08f9ffb7809f1b20c1b398d92acf4cc719874b3b2b2d9ea2f09b4a80350878a" +dependencies = [ + "futures-util", + "openssl", + "openssl-sys", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.9" @@ -1826,6 +2181,49 @@ dependencies = [ "serde", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c530c8675c1dbf98facee631536fa116b5fb6382d7dd6dc1b118d970eafe3ba" +dependencies = [ + "base64", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "343bc9466d3fe6b0f960ef45960509f84480bf4fd96f92901afe7ff3df9d3a62" + [[package]] name = "tower-service" version = "0.3.2" @@ -1839,6 +2237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fce9567bd60a67d08a16488756721ba392f24f29006402881e43b19aac64307" dependencies = [ "cfg-if", + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2002,6 +2401,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2216,7 +2621,16 @@ dependencies = [ "oid-registry", "rusticata-macros", "thiserror", - "time", + "time 0.3.14", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9e87ae5..56d3a42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,11 @@ edition = "2021" [dependencies] base64 = "0.13.0" +chrono = "0.4.22" +k8s-openapi = { version = "0.16.0", features = ["v1_22"] } +kube = "0.75.0" log = "0.4.17" +rand = "0.8.5" reqwest = "0.11.11" rocket = { version = "0.5.0-rc.2", features = ["json"] } rsa = "0.6.1" @@ -13,3 +17,6 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.85" sha1 = "0.10.2" x509-parser = "0.14.0" + +[dev-dependencies] +regex = "1.6.0" diff --git a/src/events.rs b/src/events.rs new file mode 100644 index 0000000..a65c7f0 --- /dev/null +++ b/src/events.rs @@ -0,0 +1,29 @@ +//! Event handlers +//! +//! Functions in this module are called to handle events from outside sources, +//! such as Amazon EventBridge events delivered via Amazon Simple Notification +//! Service. +use log::{debug, error}; + +use crate::k8s::create_bootstrap_token; +use crate::model::events::*; + +/// Handle an EC2 instance state change event +/// +/// This function manages the lifecycle of the Kubernetes Secret resources +/// associated with ephemeral nodes running as EC2 instances. +/// +/// When an instance starts: +/// 1. A Kubernetes bootstrap token is generated, to be used by `kubeadm` to +/// add the node to the cluster. +pub async fn on_ec2_instance_state_change(evt: Ec2InstanceStateChange) { + debug!("EC2 instance {} is now {}", &evt.instance_id, &evt.state); + if evt.state == "running" { + if let Err(e) = create_bootstrap_token(&evt.instance_id).await { + error!( + "Failed to create bootstrap token for instance {}: {}", + &evt.instance_id, e + ) + }; + } +} diff --git a/src/k8s.rs b/src/k8s.rs new file mode 100644 index 0000000..3b494e6 --- /dev/null +++ b/src/k8s.rs @@ -0,0 +1,244 @@ +//! Kubernetes Integration +use std::collections::btree_map::BTreeMap; + +use chrono::offset::Utc; +use chrono::{DateTime, Duration}; +use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::core::params::PostParams; +use kube::{Api, Client}; +use log::info; +use rand::seq::SliceRandom; + +/// The set of characters allowed to appear in bootstrap tokens +const TOKEN_CHARS: [char; 36] = [ + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '0', '1', '2', '3', + '4', '5', '6', '7', '8', '9', +]; + +/// Kubernetes Bootstrap Token +/// +/// Bootstrap tokens are typically used to add new nodes to the cluster using +/// `kubeadm join`. They are bearer tokens consisting of two parts: a token ID +/// and a secret. For additional information, see [Authenticating with +/// Bootstrap Tokens][0]. +/// +/// The Dynk8s Provisioner allocates bootstrap tokens for ephemeral nodes. +/// Each token is assigned to a specific EC2 instance; the instance ID is +/// stored in the token Secret's metadata, using the +/// `dynk8s.du5t1n.me/ec2-instance-id` label. +/// +/// [0]: https://kubernetes.io/docs/reference/access-authn-authz/bootstrap-tokens/ +#[derive(Clone, Debug)] +struct BootstrapToken { + /// The token ID (generated) + token_id: String, + /// The token secret (generated) + secret: String, + /// The date and time the token expires + expiration: DateTime, + /// The EC2 instance ID to which the token is assigned + instance_id: Option, +} + +#[allow(dead_code)] +impl BootstrapToken { + /// Generate a new token + /// + /// Initially, the token is *not* assigned to an EC2 instance. Use + /// [`Self::instance_id`] to set the associated instance ID. + pub fn new() -> Self { + let mut rng = rand::thread_rng(); + let mut token_id = String::with_capacity(6); + while token_id.len() < 6 { + token_id.push(TOKEN_CHARS.choose(&mut rng).unwrap().clone()); + } + let mut secret = String::with_capacity(16); + while secret.len() < 16 { + secret.push(TOKEN_CHARS.choose(&mut rng).unwrap().clone()); + } + let expiration = Utc::now() + Duration::hours(1); + Self { + token_id, + secret, + expiration, + instance_id: None, + } + } + + /// Return the expiration date/time + pub fn expiration(&self) -> &DateTime { + &self.expiration + } + + /// Set the ID of the EC2 instance associated with the token + pub fn instance_id(mut self, instance_id: String) -> Self { + self.instance_id = Some(instance_id); + self + } + + /// Return the secret part of the token + pub fn secret(&self) -> &str { + &self.secret + } + + /// Set the expiration date/time from a time-to-live duration + pub fn set_ttl(mut self, ttl: Duration) -> Self { + self.expiration = Utc::now() + ttl; + self + } + + /// Set the expiration date/time + pub fn set_expiration(mut self, expiration: DateTime) -> Self { + self.expiration = expiration; + self + } + + /// Return the ID part of the token + pub fn token_id(&self) -> &str { + &self.token_id + } + + /// Return the token as a string + pub fn token(&self) -> String { + format!("{}.{}", self.token_id, self.secret) + } +} + +impl Into for BootstrapToken { + /// Create a [`Secret`] for the token + /// + /// Converting a [`BootstrapToken`] into a [`Secret`] populates the fields + /// necessary to store the token in Kubernetes. The `Secret` can be passed + /// to e.g. [`kube::Api::create`]. + fn into(self) -> Secret { + let mut data = BTreeMap::::new(); + data.insert("token-id".into(), self.token_id.clone()); + data.insert("token-secret".into(), self.secret); + data.insert("expiration".into(), self.expiration.to_rfc3339()); + data.insert("usage-bootstrap-authentication".into(), "true".into()); + data.insert("usage-bootstrap-signing".into(), "true".into()); + data.insert( + "auth-extra-groups".into(), + "system:bootstrappers:kubeadm:default-node-token".into(), + ); + let mut labels = BTreeMap::::new(); + if let Some(instance_id) = self.instance_id { + labels.insert( + "dynk8s.du5t1n.me/ec2-instance-id".into(), + instance_id.into(), + ); + } + Secret { + data: None, + immutable: None, + metadata: ObjectMeta { + annotations: None, + cluster_name: None, + creation_timestamp: None, + deletion_grace_period_seconds: None, + deletion_timestamp: None, + finalizers: None, + generate_name: None, + generation: None, + labels: Some(labels), + managed_fields: None, + name: Some(format!("bootstrap-token-{}", self.token_id)), + namespace: None, + owner_references: None, + resource_version: None, + self_link: None, + uid: None, + }, + string_data: Some(data), + type_: Some("bootstrap.kubernetes.io/token".into()), + } + } +} + +/// Generate and store a bootstrap token for the specified EC2 instance +/// +/// This function generates a new bootstrap token and stores it as a Kubernetes +/// Secret resource. The token is assigned to the given EC2 instance, and will +/// be provided to that instance when it is ready to join the cluster. +pub async fn create_bootstrap_token>( + instance_id: I, +) -> Result<(), kube::Error> { + let instance_id = instance_id.as_ref(); + info!("Creating bootstrap token for instance {}", instance_id); + let token = BootstrapToken::new().instance_id(instance_id.into()); + let client = Client::try_default().await?; + let secrets: Api = Api::namespaced(client, "kube-system"); + let pp: PostParams = Default::default(); + let secret = secrets.create(&pp, &token.into()).await?; + info!("Successfully created secret {:?}", &secret.metadata.name); + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use regex::Regex; + + #[test] + fn test_bootstrap_token_new() { + let token = BootstrapToken::new(); + let id_re = Regex::new(r"^[a-z0-9]{6}$").unwrap(); + let secret_re = Regex::new(r"^[a-z0-9]{16}$").unwrap(); + let token_re = Regex::new(r"[a-z0-9]{6}\.[a-z0-9]{16}$").unwrap(); + assert!(id_re.is_match(&token.token_id())); + assert!(secret_re.is_match(&token.secret())); + assert!(token_re.is_match(&token.token())); + } + + #[test] + fn test_bootstrap_token_into_secret() { + let token = BootstrapToken::new(); + let secret: Secret = token.clone().into(); + let data = secret.string_data.unwrap(); + assert_eq!( + &secret.metadata.name, + &Some(format!("bootstrap-token-{}", token.token_id)) + ); + assert_eq!(data.get("token-id").unwrap(), &token.token_id); + assert_eq!(data.get("token-secret").unwrap(), &token.secret); + assert_eq!( + data.get("expiration").unwrap(), + &token.expiration.to_rfc3339() + ); + assert!(secret + .metadata + .labels + .unwrap() + .get("dynk8s.du5t1n.me/ec2-instance-id") + .is_none()); + } + + #[test] + fn test_bootstrap_token_into_secret_instance_id() { + let token = + BootstrapToken::new().instance_id("i-0a1b2c3d4e5f6f7f8".into()); + let secret: Secret = token.clone().into(); + let data = secret.string_data.unwrap(); + assert_eq!( + &secret.metadata.name, + &Some(format!("bootstrap-token-{}", token.token_id)) + ); + assert_eq!(data.get("token-id").unwrap(), &token.token_id); + assert_eq!(data.get("token-secret").unwrap(), &token.secret); + assert_eq!( + data.get("expiration").unwrap(), + &token.expiration.to_rfc3339() + ); + assert_eq!( + secret + .metadata + .labels + .unwrap() + .get("dynk8s.du5t1n.me/ec2-instance-id") + .unwrap(), + "i-0a1b2c3d4e5f6f7f8" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 36b4db5..463e3cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,6 @@ mod error; +mod events; +mod k8s; mod model; mod routes; mod sns; diff --git a/src/model/events.rs b/src/model/events.rs new file mode 100644 index 0000000..9113dd5 --- /dev/null +++ b/src/model/events.rs @@ -0,0 +1,46 @@ +//! Amazon EventBridge event types +//! +//! These data structures are sent by [Amazon EventBridge][0], encapsulated in +//! SNS notification messages. +//! +//! [0]: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-what-is.html +use serde::{Deserialize, Serialize}; + +/// EC2 Instance State-change Notification +/// +/// EventBridge event emitted when an EC2 instance changes state +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Ec2InstanceStateChange { + pub instance_id: String, + pub state: String, +} + +/// Enumeration of EventBridge detail objects +/// +/// EventBridge events sent by AWS services include a `detail` property, the +/// contents of which vary depending on the `detail-type` field. +#[derive(Deserialize, Serialize)] +#[serde(untagged)] +pub enum EventDetail { + Ec2InstanceStateChange(Ec2InstanceStateChange), +} + +/// EventBridge event +/// +/// See also: [Amazon EventBridge events][0] +/// +/// [0]: https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-events.html +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct Event { + pub version: String, + pub id: String, + pub detail_type: String, + pub source: String, + pub account: String, + pub time: String, + pub region: String, + pub resources: Vec, + pub detail: EventDetail, +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 0f400a7..470c8dc 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,2 +1,3 @@ //! The dynk8s provisioner data model +pub mod events; pub mod sns; diff --git a/src/sns/mod.rs b/src/sns/mod.rs index f341775..1657681 100644 --- a/src/sns/mod.rs +++ b/src/sns/mod.rs @@ -8,6 +8,8 @@ use log::{debug, error, info}; use reqwest::Url; use serde::Serialize; +use crate::events; +use crate::model::events::*; use crate::model::sns::*; use error::SnsError; use sig::SignatureVerifier; @@ -38,11 +40,23 @@ pub async fn handle_unsubscribe( /// Handle an notification message /// -/// After verifying the message signature, the message contents are written to -/// a file for later inspection. +/// This function handles varions SNS notification messages based on their +/// contents/sub-type. pub async fn handle_notify(msg: NotificationMessage) -> Result<(), SnsError> { verify(&msg, &msg.signing_cert_url).await?; save_message(&msg.topic_arn, &msg.timestamp, &msg.message_id, &msg); + let event: Event = match serde_json::from_str(&msg.message) { + Ok(evt) => evt, + Err(e) => { + error!("Failed to deserialize notification message: {}", e); + return Ok(()); + } + }; + match event.detail { + EventDetail::Ec2InstanceStateChange(d) => { + events::on_ec2_instance_state_change(d).await; + } + }; Ok(()) }