Compare commits
11 Commits
feat/try-a
...
main
Author | SHA1 | Date | |
---|---|---|---|
e94513c24f | |||
6aa90d22ea | |||
8fe00b22c5 | |||
78262a138d | |||
bcebe4bce4 | |||
1f8a5d52c4 | |||
9207ef8f81 | |||
7df629290a | |||
54829a7fe4 | |||
0ebe88a470 | |||
67af5e7aa6 |
56
.drone.yml
56
.drone.yml
@ -1,2 +1,54 @@
|
||||
kind: template
|
||||
load: cuddle-rust-cli-plan.yaml
|
||||
kind: pipeline
|
||||
name: default
|
||||
type: docker
|
||||
|
||||
steps:
|
||||
- name: load_secret
|
||||
image: debian:buster-slim
|
||||
volumes:
|
||||
- name: ssh
|
||||
path: /root/.ssh/
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: gitea_id_ed25519
|
||||
commands:
|
||||
- mkdir -p $HOME/.ssh/
|
||||
- echo "$SSH_KEY" | base64 -d > $HOME/.ssh/id_ed25519
|
||||
|
||||
- name: build
|
||||
image: kasperhermansen/cuddle:latest
|
||||
pull: always
|
||||
volumes:
|
||||
- name: ssh
|
||||
path: /root/.ssh/
|
||||
- name: dockersock
|
||||
path: /var/run
|
||||
commands:
|
||||
- eval `ssh-agent`
|
||||
- chmod -R 600 ~/.ssh
|
||||
- ssh-add
|
||||
- cuddle x build_cuddle_image
|
||||
environment:
|
||||
DOCKER_BUILDKIT: 1
|
||||
CUDDLE_SECRETS_PROVIDER: 1password
|
||||
CUDDLE_ONE_PASSWORD_DOT_ENV: ".env.ci"
|
||||
CUDDLE_SSH_AGENT: "true"
|
||||
OP_SERVICE_ACCOUNT_TOKEN:
|
||||
from_secret: op_service_account_token
|
||||
|
||||
depends_on:
|
||||
- "load_secret"
|
||||
|
||||
services:
|
||||
- name: docker
|
||||
image: docker:dind
|
||||
privileged: true
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run
|
||||
|
||||
volumes:
|
||||
- name: ssh
|
||||
temp: {}
|
||||
- name: dockersock
|
||||
temp: {}
|
||||
|
2
.env.ci
Normal file
2
.env.ci
Normal file
@ -0,0 +1,2 @@
|
||||
DOCKER_USERNAME={{ op://application/docker_hub_credentials/username }}
|
||||
DOCKER_PASSWORD={{ op://application/docker_hub_credentials/password }}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
target/
|
||||
/target
|
||||
.cuddle/
|
||||
.env
|
||||
|
2519
Cargo.lock
generated
2519
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
18
Cargo.toml
18
Cargo.toml
@ -1,19 +1,3 @@
|
||||
[workspace]
|
||||
members = ["crates/*"]
|
||||
members = ["cuddle", "examples/base", "ci"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
cuddle = { path = "crates/cuddle" }
|
||||
|
||||
anyhow = { version = "1" }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = { version = "0.1", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.18" }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
dotenv = { version = "0.15" }
|
||||
serde = { version = "1.0.197", features = ["derive"] }
|
||||
serde_json = "1.0.127"
|
||||
uuid = { version = "1.7.0", features = ["v4"] }
|
||||
|
7
LICENSE
Normal file
7
LICENSE
Normal file
@ -0,0 +1,7 @@
|
||||
Copyright 2023 Kasper J. Hermansen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
183
README.md
183
README.md
@ -1,154 +1,71 @@
|
||||
# Cuddle
|
||||
# Cuddle - Configuration and Script Manager
|
||||
|
||||
Cuddle aims to reduce the complexity of building code projects. It allows either
|
||||
individuals or organisations to share scripts and workflows, as well as keep a
|
||||
dynamic inventory of their code.
|
||||
Cuddle CLI is a Rust command-line interface application designed to manage
|
||||
configuration variables and scripts across projects. It simplifies sharing of
|
||||
code and workflows, making development and collaboration smoother and more
|
||||
efficient. This project is published on crates.io as `cuddle`.
|
||||
|
||||
At its most basic allows enforcing a schema for projects, further it allows the
|
||||
sharing of scripts, pipelines, templates, and much more.
|
||||
## Table of Contents
|
||||
|
||||
```bash
|
||||
cuddle init
|
||||
1. [Installation](#installation)
|
||||
2. [Usage](#usage)
|
||||
3. [Commands](#commands)
|
||||
4. [Configuration](#configuration)
|
||||
5. [Contributing](#contributing)
|
||||
6. [License](#license)
|
||||
|
||||
## Installation
|
||||
|
||||
Make sure you have Rust and Cargo installed. You can install Rust and Cargo from
|
||||
[https://rustup.rs/](https://rustup.rs/).
|
||||
|
||||
To install Cuddle CLI, run:
|
||||
|
||||
```sh
|
||||
cargo install cuddle
|
||||
```
|
||||
|
||||
Cuddle is meant to be used in the degree that it makes sense for you, it can be
|
||||
adopted quickly to improve code sharing, and be fully bought into to provide a
|
||||
full suite of project level solutions to offload requirements of developers.
|
||||
Start small with scripts and plans, and gradually adopt features from there as
|
||||
you need them.
|
||||
|
||||
## Usage
|
||||
|
||||
Cuddle is primarily a cli based tool, but ships with optional server components.
|
||||
Server components helps enhance the features of cuddle, such that variables can
|
||||
be enforced at runtime, actions be downloaded instead of built and much more. It
|
||||
all works on a gradual adoption process, so as an operator you can gradually add
|
||||
features as you mature and need them.
|
||||
After successful installation, you can run the CLI using the `cuddle` command:
|
||||
|
||||
A cuddle workflow is split up into:
|
||||
|
||||
- Projects: A project is what most users interface with, you can think of it as
|
||||
the cockpit of a car. You can use all the bells and whistles, but somebody
|
||||
else has built up all the user journeys you're interacting with.
|
||||
- Plans: A plan is the engine room of the car, it ties together components,
|
||||
features and requirements for use in the cockpit. The plan usually faciliates
|
||||
most of the infrastructure an application stands to outsource. Such as scripts
|
||||
to run, build, test an application, build its templates for deployment, run
|
||||
pipelines, common actions. Plans specialize in building preciely what the
|
||||
projects needs, as such your organisation or yourself, should only have a
|
||||
handful of them at most. To further reduce duplication of work between plans,
|
||||
components can be used to abstract common features required by plans, such as
|
||||
templating, individual components for templates.
|
||||
- Components: Components are a slice of features not useful in of itself, but
|
||||
used by plans to further their behavior, a sample component may be a template
|
||||
part, which includes a list of allowed ip addresses for internal
|
||||
communication, it may be how to build an ingress, ship a docker container to a
|
||||
registry, basically small individual components useful for a larger whole.
|
||||
- Actions: are code units that can take a variety of forms, golang, rust, bash,
|
||||
what have you. All of them are accessed by `cuddle do`, individual components
|
||||
can take the form of actions, if desired
|
||||
- Global: Is a set of actions and features that are available anywhere a user
|
||||
might need them. For example it can be a solution to easily log into
|
||||
production environments, release code to production, get the menu from the
|
||||
canteen, etc.
|
||||
- Personal: Is a config an org can decide the users and develoeprs fill out, it
|
||||
can help other tooling better enhance the experience. For example it may be
|
||||
useful to always have the developers email available, if for example we want
|
||||
to trigger an automatic login for them.
|
||||
|
||||
### Init
|
||||
|
||||
`cuddle init` will bootstrap a project either from scratch, or just adding
|
||||
required `cuddle.toml` parts.
|
||||
|
||||
A `cuddle.toml` is required to access project level `cuddle` commands. Such as
|
||||
`cuddle do`, `cuddle get`, `cuddle validate`, etc.
|
||||
|
||||
`cuddle.toml` looks something like this:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "some-cuddle-project"
|
||||
owner = "kjuulh"
|
||||
```sh
|
||||
cuddle [command] [options]
|
||||
```
|
||||
|
||||
What is generated out of the box is a _bare_ project. A bare project doesn't
|
||||
share any features, or enforce any requirements on its schema from its plan. It
|
||||
is most useful for projects that doesn't fit any mold, or for individual users
|
||||
simply testing out the framework.
|
||||
## Commands
|
||||
|
||||
### Actions
|
||||
Detailed documentation of the commands and options can be found in our
|
||||
[official documentation (tbd)](LINK_TO_DOCUMENTATION).
|
||||
|
||||
`cuddle actions` are project level scripts that can take a variety of forms.
|
||||
Actions are invoked via. `cuddle do` when inside of a project, most projects
|
||||
won't build actions themselves, instead they will depend on what their plan
|
||||
provides for them.
|
||||
## Configuration
|
||||
|
||||
Actions can be bootstrapped via. `cuddle init actions`, an action is slice of a
|
||||
cli. Cuddle provides a convenient way of building them, such that they are easy
|
||||
to build, maintain and operate. Most actions are written in either golang or
|
||||
rust, but bash and lua is also available.
|
||||
You can manage your configurations in a file called `cuddle.yaml`. For details
|
||||
on how to format this file, check our
|
||||
[Configuration Guide (tbd)](LINK_TO_CONFIGURATION_GUIDE).
|
||||
|
||||
```toml
|
||||
[project]
|
||||
# ...
|
||||
## Contributing
|
||||
|
||||
[project.actions.run]
|
||||
type = "bash"
|
||||
command = """
|
||||
cargo run -p some-cuddle-project -- $@
|
||||
"""
|
||||
```
|
||||
Contributions are what make the open-source community such an amazing place to
|
||||
learn, inspire, and create. Any contributions you make are **greatly
|
||||
appreciated**.
|
||||
|
||||
This action can be invoked via.
|
||||
1. Fork the Project
|
||||
2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the Branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
```bash
|
||||
cuddle do run
|
||||
```
|
||||
Please read our [Contributing Guide](LINK_TO_CONTRIBUTING_GUIDE) for more
|
||||
information.
|
||||
|
||||
Scripts are also based on convention, so if a rust action is used:
|
||||
## License
|
||||
|
||||
```bash
|
||||
cuddle init action rust
|
||||
```
|
||||
Distributed under the MIT License. See `LICENSE` for more information.
|
||||
|
||||
Nothing will be added to the `cuddle.toml` instead you'll receive a
|
||||
`actions/rust/` project where you can fill out the clis according to the
|
||||
template given.
|
||||
## Contact
|
||||
|
||||
### Plans
|
||||
|
||||
Plans are a crucial component for code sharing, enforcement of values, metrics
|
||||
and so on. Plans provide a predefined journey for how to work with a specific
|
||||
type of application. I.e. what does our organisation think a Rust application
|
||||
look like?
|
||||
|
||||
Plans are maintained via. the `plan` section of the `cuddle.toml` file
|
||||
|
||||
```toml
|
||||
[plan]
|
||||
git = "https://github.com/kjuulh/some-cuddle-plan.git"
|
||||
branch = "main"
|
||||
|
||||
# Alternatively
|
||||
plan = "https://github.com/kjuulh/some-cuddle-plan.git" # if you want the default
|
||||
|
||||
[project]
|
||||
# ...
|
||||
```
|
||||
|
||||
A plan itself will be maintained via. a `cuddle.plan.toml` file.
|
||||
|
||||
```bash
|
||||
cuddle init plan
|
||||
```
|
||||
|
||||
```toml
|
||||
[plan]
|
||||
name = "some-cuddle-plan"
|
||||
|
||||
[plan.components]
|
||||
canteen = {git = "https://github.com/kjuulh/canteen"}
|
||||
ingress = {git = "https://github.com/kjuulh/cuddle-ingress"}
|
||||
ip-allowlist = {git = "https://github.com/kjuulh/ip-allowlist"}
|
||||
```
|
||||
You can reach out to us at our
|
||||
[official contact page (tbd)](LINK_TO_CONTACT_PAGE). Please ensure to follow our
|
||||
[Code of Conduct (tbd)](LINK_TO_CONDUCT_PAGE) when interacting with our
|
||||
community.
|
||||
|
11
ci/Cargo.toml
Normal file
11
ci/Cargo.toml
Normal file
@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "ci"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dagger-sdk = "0.9.8"
|
||||
eyre = "0.6.12"
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
112
ci/src/main.rs
Normal file
112
ci/src/main.rs
Normal file
@ -0,0 +1,112 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use dagger_sdk::{
|
||||
Container, ContainerPublishOptsBuilder, Directory, HostDirectoryOptsBuilder, Query,
|
||||
QueryContainerOptsBuilder,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> eyre::Result<()> {
|
||||
let client = dagger_sdk::connect().await?;
|
||||
|
||||
let src = client.host().directory_opts(
|
||||
".",
|
||||
HostDirectoryOptsBuilder::default()
|
||||
.include(vec![
|
||||
"ci/",
|
||||
"cuddle/",
|
||||
"examples",
|
||||
"Cargo.lock",
|
||||
"Cargo.toml",
|
||||
])
|
||||
.build()?,
|
||||
);
|
||||
|
||||
client
|
||||
.container()
|
||||
.publish_opts(
|
||||
"kasperhermansen/cuddle:dev",
|
||||
ContainerPublishOptsBuilder::default()
|
||||
.platform_variants(vec![
|
||||
dind_image(client.clone(), src.clone(), "x86_64", "linux/amd64")
|
||||
.await?
|
||||
.id()
|
||||
.await?,
|
||||
dind_image(client.clone(), src.clone(), "aarch64", "linux/arm64/v8")
|
||||
.await?
|
||||
.id()
|
||||
.await?,
|
||||
])
|
||||
.build()?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dind_image(
|
||||
client: Arc<Query>,
|
||||
src: Directory,
|
||||
architecture: &str,
|
||||
platform: &str,
|
||||
) -> eyre::Result<Container> {
|
||||
let rust_bin = client
|
||||
.container_opts(
|
||||
QueryContainerOptsBuilder::default()
|
||||
.platform(platform)
|
||||
.build()?,
|
||||
)
|
||||
.from("rustlang/rust:nightly")
|
||||
.with_exec(vec![
|
||||
"rustup",
|
||||
"target",
|
||||
"add",
|
||||
&format!("{architecture}-unknown-linux-musl"),
|
||||
])
|
||||
.with_exec(vec!["update-ca-certificates"])
|
||||
.with_exec(vec!["apt-get", "update"])
|
||||
.with_exec(vec!["apt-get", "upgrade", "-y"])
|
||||
.with_exec(vec![
|
||||
"apt-get",
|
||||
"install",
|
||||
"-y",
|
||||
"-q",
|
||||
"build-essential",
|
||||
"curl",
|
||||
"git",
|
||||
"musl-tools",
|
||||
"musl-dev",
|
||||
"libz-dev",
|
||||
])
|
||||
.with_workdir("/app/cuddle/")
|
||||
.with_directory(".", src)
|
||||
.with_exec(vec![
|
||||
"cargo",
|
||||
"install",
|
||||
"--target",
|
||||
&format!("{architecture}-unknown-linux-musl"),
|
||||
"--path",
|
||||
"cuddle",
|
||||
"--profile=release",
|
||||
]);
|
||||
|
||||
let final_image = client
|
||||
.container_opts(
|
||||
QueryContainerOptsBuilder::default()
|
||||
.platform(platform)
|
||||
.build()?,
|
||||
)
|
||||
.from("docker:dind")
|
||||
.with_directory(
|
||||
"/usr/local/cargo/bin/",
|
||||
rust_bin.directory("/usr/local/cargo/bin/"),
|
||||
);
|
||||
|
||||
let path_env = final_image.env_variable("PATH").await?;
|
||||
|
||||
let final_image = final_image
|
||||
.with_env_variable("PATH", format!("{path_env}:/usr/local/cargo/bin"))
|
||||
.with_exec(vec![""]);
|
||||
|
||||
Ok(final_image)
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "cuddle-actions"
|
||||
edition = "2021"
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
clap = { workspace = true, features = ["string"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1.4.0"
|
@ -1,120 +0,0 @@
|
||||
// Cuddle actions is a two part action, it is called from cuddle itself, second the cli uses a provided sdk to expose functionality
|
||||
|
||||
use std::{collections::BTreeMap, ffi::OsString, io::Write};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
// Fix design make it so that it works like axum!
|
||||
|
||||
type ActionFn = dyn Fn() -> anyhow::Result<()> + 'static;
|
||||
|
||||
struct Action {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
f: Box<ActionFn>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ActionSchema<'a> {
|
||||
name: &'a str,
|
||||
description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct CuddleActions {
|
||||
actions: BTreeMap<String, Action>,
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct AddActionOptions {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
impl CuddleActions {
|
||||
pub fn add_action<F>(
|
||||
&mut self,
|
||||
name: &str,
|
||||
action_fn: F,
|
||||
options: &AddActionOptions,
|
||||
) -> &mut Self
|
||||
where
|
||||
F: Fn() -> anyhow::Result<()> + 'static,
|
||||
{
|
||||
self.actions.insert(
|
||||
name.into(),
|
||||
Action {
|
||||
name: name.into(),
|
||||
description: options.description.clone(),
|
||||
f: Box::new(action_fn),
|
||||
},
|
||||
);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn execute(&mut self) -> anyhow::Result<()> {
|
||||
self.execute_from(std::env::args())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn execute_from<I, T>(&mut self, items: I) -> anyhow::Result<()>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString> + Clone,
|
||||
{
|
||||
let mut do_cmd = clap::Command::new("do").subcommand_required(true);
|
||||
|
||||
for action in self.actions.values() {
|
||||
let mut do_action_cmd = clap::Command::new(action.name.clone());
|
||||
if let Some(description) = &action.description {
|
||||
do_action_cmd = do_action_cmd.about(description.clone());
|
||||
}
|
||||
|
||||
do_cmd = do_cmd.subcommand(do_action_cmd);
|
||||
}
|
||||
|
||||
let root = clap::Command::new("cuddle-action")
|
||||
.subcommand_required(true)
|
||||
.subcommand(clap::Command::new("schema"))
|
||||
.subcommand(do_cmd);
|
||||
|
||||
let matches = root.try_get_matches_from(items)?;
|
||||
match matches.subcommand().expect("subcommand to be required") {
|
||||
("schema", _args) => {
|
||||
let output = self.get_pretty_actions()?;
|
||||
|
||||
// Write all stdout to buffer
|
||||
std::io::stdout().write_all(output.as_bytes())?;
|
||||
std::io::stdout().write_all("\n".as_bytes())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
("do", args) => {
|
||||
let (command_name, _args) = args.subcommand().unwrap();
|
||||
match self.actions.get_mut(command_name) {
|
||||
Some(action) => (*action.f)(),
|
||||
None => {
|
||||
anyhow::bail!("command not found: {}", command_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("no command found"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_pretty_actions(&self) -> anyhow::Result<String> {
|
||||
let schema = self
|
||||
.actions
|
||||
.values()
|
||||
.map(|a| ActionSchema {
|
||||
name: &a.name,
|
||||
description: a.description.as_deref(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let output = serde_json::to_string_pretty(&schema)?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
}
|
@ -1,67 +0,0 @@
|
||||
use cuddle_actions::AddActionOptions;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_can_schema_no_actions() -> anyhow::Result<()> {
|
||||
let output = cuddle_actions::CuddleActions::default().get_pretty_actions()?;
|
||||
|
||||
assert_eq!("[]", &output);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_schema_simple_action() -> anyhow::Result<()> {
|
||||
let output = cuddle_actions::CuddleActions::default()
|
||||
.add_action("something", || Ok(()), &AddActionOptions::default())
|
||||
.get_pretty_actions()?;
|
||||
|
||||
assert_eq!(
|
||||
r#"[
|
||||
{
|
||||
"name": "something",
|
||||
"description": null
|
||||
}
|
||||
]"#,
|
||||
&output
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_call_simple_action() -> anyhow::Result<()> {
|
||||
cuddle_actions::CuddleActions::default()
|
||||
.add_action("something", || Ok(()), &AddActionOptions::default())
|
||||
.execute_from(vec!["cuddle-actions", "do", "something"])?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_fail_on_unknown_command() -> anyhow::Result<()> {
|
||||
let res = cuddle_actions::CuddleActions::default().execute_from(vec![
|
||||
"cuddle-actions",
|
||||
"do",
|
||||
"something",
|
||||
]);
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_cmd_can_fail() -> anyhow::Result<()> {
|
||||
let res = cuddle_actions::CuddleActions::default()
|
||||
.add_action(
|
||||
"something",
|
||||
|| anyhow::bail!("failed to run cmd"),
|
||||
&AddActionOptions::default(),
|
||||
)
|
||||
.execute_from(vec!["cuddle-actions", "do", "something"]);
|
||||
|
||||
assert!(res.is_err());
|
||||
|
||||
Ok(())
|
||||
}
|
1
crates/cuddle/.gitignore
vendored
1
crates/cuddle/.gitignore
vendored
@ -1 +0,0 @@
|
||||
/target
|
@ -1,19 +0,0 @@
|
||||
[package]
|
||||
name = "cuddle"
|
||||
edition = "2021"
|
||||
|
||||
version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap.workspace = true
|
||||
dotenv.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
toml = "0.8.19"
|
||||
fs_extra = "1.3.0"
|
@ -1 +0,0 @@
|
||||
[plan]
|
@ -1,5 +0,0 @@
|
||||
[plan]
|
||||
path = "../plan"
|
||||
|
||||
[project]
|
||||
name = "basic"
|
@ -1,5 +0,0 @@
|
||||
[plan]
|
||||
git = "ssh://git@git.front.kjuulh.io/kjuulh/cuddle-git-example.git"
|
||||
|
||||
[project]
|
||||
name = "git"
|
@ -1 +0,0 @@
|
||||
{"ProjectSchema":{"fields":{"name":{"fields":null,"type":null,"contracts":["String"],"documentation":null}},"type":null,"contracts":[],"documentation":null}}
|
@ -1,2 +0,0 @@
|
||||
[plan]
|
||||
schema = { nickel = "schema.ncl" }
|
@ -1,5 +0,0 @@
|
||||
{
|
||||
ProjectSchema = {
|
||||
name | String,..
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
[project]
|
||||
name = "schema"
|
||||
|
||||
[plan]
|
||||
path = "../plan"
|
@ -1,88 +0,0 @@
|
||||
use std::{borrow::BorrowMut, io::Write};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use get_command::GetCommand;
|
||||
|
||||
use crate::{cuddle_state::Cuddle, state::ValidatedState};
|
||||
|
||||
mod get_command;
|
||||
|
||||
pub struct Cli {
|
||||
cli: clap::Command,
|
||||
cuddle: Cuddle<ValidatedState>,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn new(cuddle: Cuddle<ValidatedState>) -> Self {
|
||||
let cli = clap::Command::new("cuddle").subcommand_required(true);
|
||||
|
||||
Self { cli, cuddle }
|
||||
}
|
||||
|
||||
pub async fn setup(mut self) -> anyhow::Result<Self> {
|
||||
let commands = self.get_commands().await?;
|
||||
|
||||
self.cli = self.cli.subcommands(commands);
|
||||
|
||||
// TODO: Add global
|
||||
// TODO: Add components
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
async fn get_commands(&self) -> anyhow::Result<Vec<clap::Command>> {
|
||||
Ok(vec![
|
||||
clap::Command::new("do").subcommand_required(true),
|
||||
clap::Command::new("get")
|
||||
.about(GetCommand::description())
|
||||
.arg(
|
||||
clap::Arg::new("query")
|
||||
.required(true)
|
||||
.help("query is how values are extracted, '.project.name' etc"),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub async fn execute(self) -> anyhow::Result<()> {
|
||||
match self
|
||||
.cli
|
||||
.get_matches_from(std::env::args())
|
||||
.subcommand()
|
||||
.ok_or(anyhow::anyhow!("failed to find subcommand"))?
|
||||
{
|
||||
("do", _args) => {
|
||||
tracing::debug!("executing do");
|
||||
}
|
||||
("get", args) => {
|
||||
let query = args
|
||||
.get_one::<String>("query")
|
||||
.ok_or(anyhow!("query is required"))?;
|
||||
|
||||
let res = GetCommand::new(self.cuddle).execute(query).await?;
|
||||
|
||||
std::io::stdout().write_all(res.as_bytes())?;
|
||||
std::io::stdout().write_all("\n".as_bytes())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn add_project_commands(&self) -> anyhow::Result<Vec<clap::Command>> {
|
||||
if let Some(_project) = self.cuddle.state.project.as_ref() {
|
||||
// Add project level commands
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn add_plan_commands(self) -> anyhow::Result<Self> {
|
||||
if let Some(_plan) = self.cuddle.state.plan.as_ref() {
|
||||
// Add plan level commands
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
}
|
@ -1,124 +0,0 @@
|
||||
use crate::{
|
||||
cuddle_state::Cuddle,
|
||||
state::{
|
||||
validated_project::{Project, Value},
|
||||
ValidatedState,
|
||||
},
|
||||
};
|
||||
|
||||
pub struct GetCommand {
|
||||
query_engine: ProjectQueryEngine,
|
||||
}
|
||||
|
||||
impl GetCommand {
|
||||
pub fn new(cuddle: Cuddle<ValidatedState>) -> Self {
|
||||
Self {
|
||||
query_engine: ProjectQueryEngine::new(
|
||||
&cuddle
|
||||
.state
|
||||
.project
|
||||
.expect("we should always have a project if get command is available"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(&self, query: &str) -> anyhow::Result<String> {
|
||||
let res = self
|
||||
.query_engine
|
||||
.query(query)?
|
||||
.ok_or(anyhow::anyhow!("query was not found in project"))?;
|
||||
|
||||
match res {
|
||||
Value::String(s) => Ok(s),
|
||||
Value::Bool(b) => Ok(b.to_string()),
|
||||
Value::Array(value) => {
|
||||
let val = serde_json::to_string_pretty(&value)?;
|
||||
Ok(val)
|
||||
}
|
||||
Value::Map(value) => {
|
||||
let val = serde_json::to_string_pretty(&value)?;
|
||||
Ok(val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description() -> String {
|
||||
"get returns a given variable from the project given a key, following a jq like schema (.project.name, etc.)"
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProjectQueryEngine {
|
||||
project: Project,
|
||||
}
|
||||
|
||||
impl ProjectQueryEngine {
|
||||
pub fn new(project: &Project) -> Self {
|
||||
Self {
|
||||
project: project.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn query(&self, query: &str) -> anyhow::Result<Option<Value>> {
|
||||
let parts = query
|
||||
.split('.')
|
||||
.filter(|i| !i.is_empty())
|
||||
.collect::<Vec<&str>>();
|
||||
|
||||
Ok(self.traverse(&parts, &self.project.value))
|
||||
}
|
||||
|
||||
fn traverse(&self, query: &[&str], value: &Value) -> Option<Value> {
|
||||
match query.split_first() {
|
||||
Some((key, rest)) => match value {
|
||||
Value::Map(items) => {
|
||||
let item = items.get(*key)?;
|
||||
|
||||
self.traverse(rest, item)
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"key: {} doesn't have a corresponding value: {:?}",
|
||||
key,
|
||||
value
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => Some(value.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_can_query_item() -> anyhow::Result<()> {
|
||||
let project = ProjectQueryEngine::new(&Project {
|
||||
value: Value::Map(
|
||||
[(
|
||||
String::from("project"),
|
||||
Value::Map(
|
||||
[(
|
||||
String::from("name"),
|
||||
Value::String(String::from("something")),
|
||||
)]
|
||||
.into(),
|
||||
),
|
||||
)]
|
||||
.into(),
|
||||
),
|
||||
root: PathBuf::new(),
|
||||
});
|
||||
|
||||
let res = project.query(".project.name")?;
|
||||
|
||||
assert_eq!(Some(Value::String("something".into())), res);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
use crate::plan::{ClonedPlan, Plan};
|
||||
use crate::project::ProjectPlan;
|
||||
use crate::state::{self, ValidatedState};
|
||||
|
||||
pub struct Start {}
|
||||
pub struct PrepareProject {
|
||||
project: Option<ProjectPlan>,
|
||||
}
|
||||
|
||||
pub struct PreparePlan {
|
||||
project: Option<ProjectPlan>,
|
||||
plan: Option<ClonedPlan>,
|
||||
}
|
||||
|
||||
pub struct Cuddle<S = Start> {
|
||||
pub state: S,
|
||||
}
|
||||
|
||||
// Cuddle maintains the context for cuddle to use
|
||||
// Stage 1 figure out which state to display
|
||||
// Stage 2 prepare plan
|
||||
// Stage 3 validate settings, build actions, prepare
|
||||
|
||||
impl Cuddle<Start> {
|
||||
pub fn default() -> Self {
|
||||
Self { state: Start {} }
|
||||
}
|
||||
|
||||
pub async fn prepare_project(&self) -> anyhow::Result<Cuddle<PrepareProject>> {
|
||||
let project = ProjectPlan::from_current_path().await?;
|
||||
|
||||
Ok(Cuddle {
|
||||
state: PrepareProject { project },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Cuddle<PrepareProject> {
|
||||
pub async fn prepare_plan(&self) -> anyhow::Result<Cuddle<PreparePlan>> {
|
||||
let plan = if let Some(project) = &self.state.project {
|
||||
Plan::new().clone_from_project(project).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Cuddle {
|
||||
state: PreparePlan {
|
||||
project: self.state.project.clone(),
|
||||
plan,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Cuddle<PreparePlan> {
|
||||
pub async fn build_state(&self) -> anyhow::Result<Cuddle<ValidatedState>> {
|
||||
let state = if let Some(project) = &self.state.project {
|
||||
let state = state::State::new();
|
||||
let raw_state = state.build_state(project, &self.state.plan).await?;
|
||||
|
||||
state.validate_state(&raw_state).await?
|
||||
} else {
|
||||
ValidatedState::default()
|
||||
};
|
||||
|
||||
Ok(Cuddle { state })
|
||||
}
|
||||
}
|
@ -1,27 +0,0 @@
|
||||
use cli::Cli;
|
||||
use cuddle_state::Cuddle;
|
||||
|
||||
mod cli;
|
||||
mod cuddle_state;
|
||||
mod plan;
|
||||
mod project;
|
||||
mod schema_validator;
|
||||
mod state;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenv::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let cuddle = Cuddle::default()
|
||||
.prepare_project()
|
||||
.await?
|
||||
.prepare_plan()
|
||||
.await?
|
||||
.build_state()
|
||||
.await?;
|
||||
|
||||
Cli::new(cuddle).setup().await?.execute().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
@ -1,202 +0,0 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fs_extra::dir::CopyOptions;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::project::{self, ProjectPlan};
|
||||
|
||||
pub const CUDDLE_PLAN_FOLDER: &str = "plan";
|
||||
pub const CUDDLE_PROJECT_WORKSPACE: &str = ".cuddle";
|
||||
pub const CUDDLE_PLAN_FILE: &str = "cuddle.plan.toml";
|
||||
|
||||
pub trait PlanPathExt {
|
||||
fn plan_path(&self) -> PathBuf;
|
||||
}
|
||||
|
||||
impl PlanPathExt for project::ProjectPlan {
|
||||
fn plan_path(&self) -> PathBuf {
|
||||
self.root
|
||||
.join(CUDDLE_PROJECT_WORKSPACE)
|
||||
.join(CUDDLE_PLAN_FOLDER)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RawPlan {
|
||||
pub config: RawPlanConfig,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl RawPlan {
|
||||
pub fn new(config: RawPlanConfig, root: &Path) -> Self {
|
||||
Self {
|
||||
config,
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(content: &str, root: &Path) -> anyhow::Result<Self> {
|
||||
let config: RawPlanConfig = toml::from_str(content)?;
|
||||
|
||||
Ok(Self::new(config, root))
|
||||
}
|
||||
|
||||
pub async fn from_path(path: &Path) -> anyhow::Result<Self> {
|
||||
let cuddle_file = path.join(CUDDLE_PLAN_FILE);
|
||||
|
||||
tracing::trace!(
|
||||
path = cuddle_file.display().to_string(),
|
||||
"searching for cuddle.toml project file"
|
||||
);
|
||||
|
||||
if !cuddle_file.exists() {
|
||||
anyhow::bail!("no cuddle.toml project file found");
|
||||
}
|
||||
|
||||
let cuddle_plan_file = tokio::fs::read_to_string(cuddle_file).await?;
|
||||
|
||||
Self::from_file(&cuddle_plan_file, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct RawPlanConfig {
|
||||
pub plan: RawPlanConfigSection,
|
||||
}
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct RawPlanConfigSection {
|
||||
pub schema: Option<RawPlanSchema>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum RawPlanSchema {
|
||||
Nickel { nickel: PathBuf },
|
||||
}
|
||||
|
||||
pub struct Plan {}
|
||||
impl Plan {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub async fn clone_from_project(
|
||||
&self,
|
||||
project: &ProjectPlan,
|
||||
) -> anyhow::Result<Option<ClonedPlan>> {
|
||||
if !project.plan_path().exists() {
|
||||
if project.has_plan() {
|
||||
self.prepare_plan(project).await?;
|
||||
}
|
||||
|
||||
match project.get_plan() {
|
||||
project::Plan::None => Ok(None),
|
||||
project::Plan::Git(url) => Ok(Some(self.git_plan(project, url).await?)),
|
||||
project::Plan::Folder(folder) => {
|
||||
Ok(Some(self.folder_plan(project, &folder).await?))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match project.get_plan() {
|
||||
project::Plan::Folder(folder) => {
|
||||
self.clean_plan(project).await?;
|
||||
self.prepare_plan(project).await?;
|
||||
|
||||
Ok(Some(self.folder_plan(project, &folder).await?))
|
||||
}
|
||||
project::Plan::Git(_git) => Ok(Some(ClonedPlan {})),
|
||||
project::Plan::None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_plan(&self, project: &ProjectPlan) -> anyhow::Result<()> {
|
||||
tracing::trace!("preparing workspace");
|
||||
|
||||
tokio::fs::create_dir_all(project.plan_path()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clean_plan(&self, project: &ProjectPlan) -> anyhow::Result<()> {
|
||||
tracing::trace!("clean plan");
|
||||
|
||||
tokio::fs::remove_dir_all(project.plan_path()).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn git_plan(&self, project: &ProjectPlan, url: String) -> anyhow::Result<ClonedPlan> {
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(["clone", &url, &project.plan_path().display().to_string()]);
|
||||
|
||||
tracing::debug!(url = url, "cloning git plan");
|
||||
|
||||
let output = cmd.output().await?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"failed to clone: {}, output: {} {}",
|
||||
url,
|
||||
std::str::from_utf8(&output.stdout)?,
|
||||
std::str::from_utf8(&output.stderr)?,
|
||||
)
|
||||
}
|
||||
|
||||
Ok(ClonedPlan {})
|
||||
}
|
||||
|
||||
async fn folder_plan(&self, project: &ProjectPlan, path: &Path) -> anyhow::Result<ClonedPlan> {
|
||||
tracing::trace!(
|
||||
src = path.display().to_string(),
|
||||
dest = project.plan_path().display().to_string(),
|
||||
"copying src into plan dest"
|
||||
);
|
||||
|
||||
let mut items_stream = tokio::fs::read_dir(path).await?;
|
||||
let mut items = Vec::new();
|
||||
while let Some(item) = items_stream.next_entry().await? {
|
||||
items.push(item.path());
|
||||
}
|
||||
|
||||
fs_extra::copy_items(
|
||||
&items,
|
||||
project.plan_path(),
|
||||
&CopyOptions::default()
|
||||
.overwrite(true)
|
||||
.depth(0)
|
||||
.copy_inside(false),
|
||||
)?;
|
||||
|
||||
Ok(ClonedPlan {})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ClonedPlan {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_schema_plan() -> anyhow::Result<()> {
|
||||
let plan = RawPlan::from_file(
|
||||
r##"
|
||||
[plan]
|
||||
schema = {nickel = "contract.ncl"}
|
||||
"##,
|
||||
&PathBuf::new(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
RawPlanConfig {
|
||||
plan: RawPlanConfigSection {
|
||||
schema: Some(RawPlanSchema::Nickel {
|
||||
nickel: "contract.ncl".into()
|
||||
}),
|
||||
}
|
||||
},
|
||||
plan.config,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
use std::{
|
||||
env::current_dir,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CUDDLE_PROJECT_FILE: &str = "cuddle.toml";
|
||||
|
||||
pub struct RawProject {
|
||||
pub config: RawConfig,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl RawProject {
|
||||
pub fn new(config: RawConfig, root: &Path) -> Self {
|
||||
Self {
|
||||
config,
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(content: &str, root: &Path) -> anyhow::Result<Self> {
|
||||
let config: RawConfig = toml::from_str(content)?;
|
||||
|
||||
Ok(Self::new(config, root))
|
||||
}
|
||||
|
||||
pub async fn from_path(path: &Path) -> anyhow::Result<Self> {
|
||||
let cuddle_file = path.join(CUDDLE_PROJECT_FILE);
|
||||
|
||||
tracing::trace!(
|
||||
path = cuddle_file.display().to_string(),
|
||||
"searching for cuddle.toml project file"
|
||||
);
|
||||
|
||||
if !cuddle_file.exists() {
|
||||
anyhow::bail!("no cuddle.toml project file found");
|
||||
}
|
||||
|
||||
let cuddle_project_file = tokio::fs::read_to_string(cuddle_file).await?;
|
||||
|
||||
Self::from_file(&cuddle_project_file, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProjectPlan {
|
||||
config: ProjectPlanConfig,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl ProjectPlan {
|
||||
pub fn new(config: ProjectPlanConfig, root: PathBuf) -> Self {
|
||||
Self { config, root }
|
||||
}
|
||||
|
||||
pub fn from_file(content: &str, root: PathBuf) -> anyhow::Result<Self> {
|
||||
let config: ProjectPlanConfig = toml::from_str(content)?;
|
||||
|
||||
Ok(Self::new(config, root))
|
||||
}
|
||||
|
||||
pub async fn from_current_path() -> anyhow::Result<Option<Self>> {
|
||||
let cur_dir = current_dir()?;
|
||||
let cuddle_file = cur_dir.join(CUDDLE_PROJECT_FILE);
|
||||
|
||||
tracing::trace!(
|
||||
path = cuddle_file.display().to_string(),
|
||||
"searching for cuddle.toml project file"
|
||||
);
|
||||
|
||||
if !cuddle_file.exists() {
|
||||
tracing::debug!("no cuddle.toml project file found");
|
||||
// We may want to recursively search for the file (towards root)
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let cuddle_project_file = tokio::fs::read_to_string(cuddle_file).await?;
|
||||
|
||||
Ok(Some(Self::from_file(&cuddle_project_file, cur_dir)?))
|
||||
}
|
||||
|
||||
pub fn has_plan(&self) -> bool {
|
||||
self.config.plan.is_some()
|
||||
}
|
||||
|
||||
pub fn get_plan(&self) -> Plan {
|
||||
match &self.config.plan {
|
||||
Some(PlanConfig::Bare(git)) | Some(PlanConfig::Git { git }) => Plan::Git(git.clone()),
|
||||
Some(PlanConfig::Folder { path }) => Plan::Folder(path.clone()),
|
||||
None => Plan::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Plan {
|
||||
None,
|
||||
Git(String),
|
||||
Folder(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct RawConfig {
|
||||
project: ProjectConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct ProjectConfig {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
pub struct ProjectPlanConfig {
|
||||
plan: Option<PlanConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum PlanConfig {
|
||||
Bare(String),
|
||||
Git { git: String },
|
||||
Folder { path: PathBuf },
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_simple_file() -> anyhow::Result<()> {
|
||||
let project = ProjectPlan::from_file(
|
||||
r##"
|
||||
[plan]
|
||||
git = "https://github.com/kjuulh/some-cuddle-project"
|
||||
"##,
|
||||
PathBuf::new(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
ProjectPlanConfig {
|
||||
plan: Some(PlanConfig::Git {
|
||||
git: "https://github.com/kjuulh/some-cuddle-project".into()
|
||||
})
|
||||
},
|
||||
project.config
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_simple_file_bare() -> anyhow::Result<()> {
|
||||
let project = ProjectPlan::from_file(
|
||||
r##"
|
||||
plan = "https://github.com/kjuulh/some-cuddle-project"
|
||||
"##,
|
||||
PathBuf::new(),
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
ProjectPlanConfig {
|
||||
plan: Some(PlanConfig::Bare(
|
||||
"https://github.com/kjuulh/some-cuddle-project".into()
|
||||
))
|
||||
},
|
||||
project.config
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_can_parse_simple_file_none() -> anyhow::Result<()> {
|
||||
let project = ProjectPlan::from_file(r##""##, PathBuf::new())?;
|
||||
|
||||
assert_eq!(ProjectPlanConfig { plan: None }, project.config);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
use nickel::NickelSchemaValidator;
|
||||
|
||||
use crate::{
|
||||
plan::{RawPlan, RawPlanSchema},
|
||||
project::RawProject,
|
||||
};
|
||||
|
||||
mod nickel;
|
||||
|
||||
pub struct SchemaValidator {}
|
||||
|
||||
impl SchemaValidator {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn validate(&self, plan: &RawPlan, project: &RawProject) -> anyhow::Result<Option<()>> {
|
||||
let schema = match &plan.config.plan.schema {
|
||||
Some(schema) => schema,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
match schema {
|
||||
RawPlanSchema::Nickel { nickel } => Ok(Some(NickelSchemaValidator::validate(
|
||||
plan, project, nickel,
|
||||
)?)),
|
||||
}
|
||||
}
|
||||
}
|
@ -1,111 +0,0 @@
|
||||
use std::{
|
||||
env::temp_dir,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
plan::RawPlan,
|
||||
project::{RawProject, CUDDLE_PROJECT_FILE},
|
||||
};
|
||||
|
||||
pub trait NickelPlanExt {
|
||||
fn schema_path(&self, schema: &Path) -> PathBuf;
|
||||
}
|
||||
|
||||
impl NickelPlanExt for RawPlan {
|
||||
fn schema_path(&self, schema: &Path) -> PathBuf {
|
||||
self.root.join(schema)
|
||||
}
|
||||
}
|
||||
|
||||
pub trait NickelProjectExt {
|
||||
fn project_path(&self) -> PathBuf;
|
||||
}
|
||||
|
||||
impl NickelProjectExt for RawProject {
|
||||
fn project_path(&self) -> PathBuf {
|
||||
self.root.join(CUDDLE_PROJECT_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_contract_file() -> anyhow::Result<TempDirGuard> {
|
||||
let p = temp_dir()
|
||||
.join("cuddle")
|
||||
.join("nickel-contracts")
|
||||
.join(Uuid::new_v4().to_string());
|
||||
|
||||
std::fs::create_dir_all(&p)?;
|
||||
|
||||
let file = p.join("contract.ncl");
|
||||
|
||||
Ok(TempDirGuard { dir: p, file })
|
||||
}
|
||||
|
||||
pub struct TempDirGuard {
|
||||
dir: PathBuf,
|
||||
file: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for TempDirGuard {
|
||||
fn drop(&mut self) {
|
||||
if let Err(e) = std::fs::remove_dir_all(&self.dir) {
|
||||
panic!("failed to remove tempdir: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for TempDirGuard {
|
||||
type Target = PathBuf;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.file
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NickelSchemaValidator {}
|
||||
impl NickelSchemaValidator {
|
||||
pub fn validate(plan: &RawPlan, project: &RawProject, nickel: &Path) -> anyhow::Result<()> {
|
||||
let nickel_file = plan.schema_path(nickel);
|
||||
let cuddle_file = project.project_path();
|
||||
|
||||
let nickel_file = format!(
|
||||
r##"
|
||||
let {{ProjectSchema, ..}} = import "{}" in
|
||||
|
||||
let Schema = {{
|
||||
project | ProjectSchema, ..
|
||||
}} in
|
||||
|
||||
{{
|
||||
|
||||
config | Schema = import "{}"
|
||||
}}
|
||||
|
||||
"##,
|
||||
nickel_file.display(),
|
||||
cuddle_file.display()
|
||||
);
|
||||
|
||||
let contract_file = unique_contract_file()?;
|
||||
|
||||
std::fs::write(contract_file.as_path(), nickel_file)?;
|
||||
|
||||
let mut cmd = std::process::Command::new("nickel");
|
||||
|
||||
cmd.args(["export", &contract_file.display().to_string()]);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"failed to run nickel command: output: {} {}",
|
||||
std::str::from_utf8(&output.stdout)?,
|
||||
std::str::from_utf8(&output.stderr)?
|
||||
)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
use validated_project::Project;
|
||||
|
||||
use crate::{
|
||||
plan::{self, ClonedPlan, PlanPathExt},
|
||||
project::{self, ProjectPlan},
|
||||
schema_validator::SchemaValidator,
|
||||
};
|
||||
|
||||
pub mod validated_project;
|
||||
|
||||
pub struct State {}
|
||||
impl State {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub async fn build_state(
|
||||
&self,
|
||||
project_plan: &ProjectPlan,
|
||||
cloned_plan: &Option<ClonedPlan>,
|
||||
) -> anyhow::Result<RawState> {
|
||||
let project = project::RawProject::from_path(&project_plan.root).await?;
|
||||
let plan = if let Some(_cloned_plan) = cloned_plan {
|
||||
Some(plan::RawPlan::from_path(&project_plan.plan_path()).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(RawState { project, plan })
|
||||
}
|
||||
|
||||
pub async fn validate_state(&self, state: &RawState) -> anyhow::Result<ValidatedState> {
|
||||
// 2. Prepare context for actions and components
|
||||
|
||||
if let Some(plan) = &state.plan {
|
||||
SchemaValidator::new().validate(plan, &state.project)?;
|
||||
}
|
||||
|
||||
// 3. Match against schema from plan
|
||||
let project = validated_project::Project::from_path(&state.project.root).await?;
|
||||
|
||||
Ok(ValidatedState {
|
||||
project: Some(project),
|
||||
plan: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RawState {
|
||||
project: project::RawProject,
|
||||
plan: Option<plan::RawPlan>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ValidatedState {
|
||||
pub project: Option<Project>,
|
||||
pub plan: Option<Plan>,
|
||||
}
|
||||
|
||||
pub struct Plan {}
|
@ -1,75 +0,0 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde::Serialize;
|
||||
use toml::Table;
|
||||
|
||||
use crate::project::CUDDLE_PROJECT_FILE;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Project {
|
||||
pub value: Value,
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(value: Value, root: &Path) -> Self {
|
||||
Self {
|
||||
value,
|
||||
root: root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_file(content: &str, root: &Path) -> anyhow::Result<Self> {
|
||||
let table: Table = toml::from_str(content)?;
|
||||
|
||||
let project = table
|
||||
.get("project")
|
||||
.ok_or(anyhow!("cuddle.toml doesn't provide a [project] table"))?;
|
||||
|
||||
let value: Value = project.into();
|
||||
let value = Value::Map([("project".to_string(), value)].into());
|
||||
|
||||
Ok(Self::new(value, root))
|
||||
}
|
||||
|
||||
pub async fn from_path(path: &Path) -> anyhow::Result<Self> {
|
||||
let cuddle_file = path.join(CUDDLE_PROJECT_FILE);
|
||||
|
||||
if !cuddle_file.exists() {
|
||||
anyhow::bail!("no cuddle.toml project file found");
|
||||
}
|
||||
|
||||
let cuddle_project_file = tokio::fs::read_to_string(cuddle_file).await?;
|
||||
|
||||
Self::from_file(&cuddle_project_file, path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Eq, Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Value {
|
||||
String(String),
|
||||
Bool(bool),
|
||||
Array(Vec<Value>),
|
||||
Map(BTreeMap<String, Value>),
|
||||
}
|
||||
|
||||
impl From<&toml::Value> for Value {
|
||||
fn from(value: &toml::Value) -> Self {
|
||||
match value {
|
||||
toml::Value::String(s) => Self::String(s.clone()),
|
||||
toml::Value::Integer(i) => Self::String(i.to_string()),
|
||||
toml::Value::Float(f) => Self::String(f.to_string()),
|
||||
toml::Value::Boolean(b) => Self::Bool(*b),
|
||||
toml::Value::Datetime(dt) => Self::String(dt.to_string()),
|
||||
toml::Value::Array(array) => Self::Array(array.iter().map(|i| i.into()).collect()),
|
||||
toml::Value::Table(tbl) => {
|
||||
Self::Map(tbl.iter().map(|(k, v)| (k.clone(), v.into())).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
21
cuddle.yaml
21
cuddle.yaml
@ -1,15 +1,20 @@
|
||||
# yaml-language-server: $schema=https://git.front.kjuulh.io/kjuulh/cuddle/raw/branch/main/schemas/base.json
|
||||
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-cli-plan.git"
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-base.git"
|
||||
|
||||
vars:
|
||||
service: "cuddle"
|
||||
registry: kasperhermansen
|
||||
|
||||
please:
|
||||
project:
|
||||
owner: kjuulh
|
||||
repository: "cuddle"
|
||||
branch: "main"
|
||||
settings:
|
||||
api_url: "https://git.front.kjuulh.io"
|
||||
scripts:
|
||||
install:
|
||||
type: shell
|
||||
build_cuddle_image:
|
||||
type: shell
|
||||
args:
|
||||
docker_username:
|
||||
key: "DOCKER_USERNAME"
|
||||
type: env
|
||||
docker_password:
|
||||
key: "DOCKER_PASSWORD"
|
||||
type: env
|
||||
|
51
cuddle/Cargo.toml
Normal file
51
cuddle/Cargo.toml
Normal file
@ -0,0 +1,51 @@
|
||||
[package]
|
||||
name = "cuddle"
|
||||
description = "cuddle is a shuttle inspired script and configuration management tool. It enables sharing of workflows on developers workstations and ci"
|
||||
repository = "https://git.front.kjuulh.io/kjuulh/cuddle"
|
||||
readme = "../README.md"
|
||||
license-file = "../LICENSE"
|
||||
publish = true
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "cuddle"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { version = "1.0.79", features = ["backtrace"] }
|
||||
serde = { version = "1.0.196", features = ["derive"] }
|
||||
serde_yaml = "0.9.31"
|
||||
walkdir = "2.4.0"
|
||||
git2 = { version = "0.18.2", default-features = false, features = [
|
||||
"vendored-libgit2",
|
||||
"vendored-openssl",
|
||||
"ssh",
|
||||
] }
|
||||
clap = { version = "4.4.18", features = ["env", "string"] }
|
||||
envconfig = "0.10.0"
|
||||
dirs = "5.0.1"
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.18", features = ["json", "env-filter"] }
|
||||
log = { version = "0.4.20", features = ["std", "kv_unstable"] }
|
||||
tera = "1.19.1"
|
||||
openssl = { version = "0.10.63", features = ["vendored"] }
|
||||
libz-sys = { version = "1.1.15", default-features = false, features = [
|
||||
"libc",
|
||||
"static",
|
||||
] }
|
||||
inquire = { version = "0.6.2", features = ["console"] }
|
||||
tempfile = { version = "3.10.0" }
|
||||
serde_json = "1.0.113"
|
||||
rlua = "0.19.8"
|
||||
rlua-searcher = "0.1.0"
|
||||
dotenv = { version = "0.15.0", features = ["clap"] }
|
||||
blake3 = "1.5.0"
|
||||
tokio = { version = "1.36.0", features = ["full"] }
|
||||
futures-util = "0.3.30"
|
||||
fs_extra = "1.3.0"
|
||||
|
||||
[dependencies.reqwest]
|
||||
version = "0.11"
|
||||
default-features = false
|
||||
features = ["rustls-tls", "json"]
|
366
cuddle/src/actions/mod.rs
Normal file
366
cuddle/src/actions/mod.rs
Normal file
@ -0,0 +1,366 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::ArgMatches;
|
||||
use rlua::Lua;
|
||||
use rlua_searcher::AddSearcher;
|
||||
|
||||
use crate::{
|
||||
actions::shell::ShellAction,
|
||||
model::{CuddleScript, CuddleShellScriptArg, CuddleVariable},
|
||||
};
|
||||
|
||||
pub mod shell;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CuddleAction {
|
||||
pub script: CuddleScript,
|
||||
pub path: PathBuf,
|
||||
pub description: Option<String>,
|
||||
pub name: String,
|
||||
}
|
||||
#[allow(dead_code)]
|
||||
impl CuddleAction {
|
||||
pub fn new(
|
||||
script: CuddleScript,
|
||||
path: PathBuf,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
script,
|
||||
path,
|
||||
name,
|
||||
description,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
self,
|
||||
matches: &ArgMatches,
|
||||
variables: Vec<CuddleVariable>,
|
||||
) -> anyhow::Result<()> {
|
||||
match self.script {
|
||||
CuddleScript::Shell(s) => {
|
||||
let mut arg_variables: Vec<CuddleVariable> = vec![];
|
||||
if let Some(args) = s.args {
|
||||
for (k, v) in args {
|
||||
let var = match v {
|
||||
CuddleShellScriptArg::Env(e) => {
|
||||
let env_var = matches.get_one::<String>(&k).cloned().ok_or(
|
||||
anyhow::anyhow!(
|
||||
"failed to find env variable with key: {}",
|
||||
&e.key
|
||||
),
|
||||
)?;
|
||||
|
||||
CuddleVariable::new(k.clone(), env_var)
|
||||
}
|
||||
CuddleShellScriptArg::Flag(flag) => {
|
||||
match matches.get_one::<String>(&flag.name) {
|
||||
Some(flag_var) => {
|
||||
CuddleVariable::new(k.clone(), flag_var.clone())
|
||||
}
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
arg_variables.push(var);
|
||||
}
|
||||
} else {
|
||||
arg_variables = vec![]
|
||||
};
|
||||
|
||||
let mut vars = variables.clone();
|
||||
vars.append(&mut arg_variables);
|
||||
|
||||
log::trace!("preparing to run action");
|
||||
|
||||
match ShellAction::new(
|
||||
self.name.clone(),
|
||||
format!(
|
||||
"{}/scripts/{}.sh",
|
||||
self.path
|
||||
.to_str()
|
||||
.expect("action doesn't have a name, this should never happen"),
|
||||
self.name
|
||||
),
|
||||
)
|
||||
.execute(vars)
|
||||
{
|
||||
Ok(()) => {
|
||||
log::trace!("finished running action");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("{}", e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
CuddleScript::Dagger(_d) => Err(anyhow::anyhow!("not implemented yet!")),
|
||||
CuddleScript::Lua(l) => {
|
||||
let lua = Lua::new();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
//map.insert("init".into(), "print(\"something\")".into());
|
||||
|
||||
let lua_dir = PathBuf::new().join(&self.path).join("scripts").join("lua");
|
||||
if lua_dir.exists() {
|
||||
let absolute_lua_dir = lua_dir.canonicalize()?;
|
||||
for entry in walkdir::WalkDir::new(&lua_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
if entry.metadata()?.is_file() {
|
||||
let full_file_path = entry.path().canonicalize()?;
|
||||
let relative_module_path =
|
||||
full_file_path.strip_prefix(&absolute_lua_dir)?;
|
||||
let module_path = relative_module_path
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.trim_end_matches("/init.lua")
|
||||
.trim_end_matches(".lua")
|
||||
.replace("/", ".");
|
||||
let contents = std::fs::read_to_string(entry.path())?;
|
||||
tracing::trace!(module_path = &module_path, "adding lua file");
|
||||
|
||||
map.insert(module_path.into(), contents.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
let lua_rocks_dir = PathBuf::new()
|
||||
.join(&self.path)
|
||||
.join("lua_modules")
|
||||
.join("share")
|
||||
.join("lua")
|
||||
.join("5.4");
|
||||
if lua_rocks_dir.exists() {
|
||||
let absolute_lua_dir = lua_rocks_dir.canonicalize()?;
|
||||
for entry in walkdir::WalkDir::new(&lua_rocks_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
if entry.metadata()?.is_file() {
|
||||
let full_file_path = entry.path().canonicalize()?;
|
||||
let relative_module_path =
|
||||
full_file_path.strip_prefix(&absolute_lua_dir)?;
|
||||
let module_path = relative_module_path
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
.trim_end_matches("/init.lua")
|
||||
.trim_end_matches(".lua")
|
||||
.replace("/", ".");
|
||||
let contents = std::fs::read_to_string(entry.path())?;
|
||||
tracing::trace!(module_path = &module_path, "adding lua file");
|
||||
|
||||
map.insert(module_path.into(), contents.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lua.context::<_, anyhow::Result<()>>(|lua_ctx| {
|
||||
lua_ctx.add_searcher(map)?;
|
||||
let globals = lua_ctx.globals();
|
||||
|
||||
let lua_script_entry = std::fs::read_to_string(
|
||||
PathBuf::new()
|
||||
.join(&self.path)
|
||||
.join("scripts")
|
||||
.join(format!("{}.lua", &self.name)),
|
||||
)
|
||||
.context("failed to find lua script")?;
|
||||
|
||||
lua_ctx
|
||||
.load(&lua_script_entry)
|
||||
.set_name(&self.name)?
|
||||
.exec()?;
|
||||
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
CuddleScript::Rust(script) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub mod rust_action {
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
use anyhow::Context;
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::Method;
|
||||
use tokio::{fs::File, io::AsyncWriteExt};
|
||||
|
||||
use crate::model::{CuddleRustScript, CuddleRustUpstream, CuddleVariable};
|
||||
|
||||
pub struct RustActionConfig {
|
||||
pub config_dir: PathBuf,
|
||||
pub cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for RustActionConfig {
|
||||
fn default() -> Self {
|
||||
let config = dirs::config_dir().expect("to be able to find a valid .config dir");
|
||||
let cache = dirs::cache_dir().expect("to be able to find a valid .cache dir");
|
||||
|
||||
Self {
|
||||
config_dir: config,
|
||||
cache_dir: cache,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RustAction {
|
||||
pub config: RustActionConfig,
|
||||
pub plan: String,
|
||||
pub binary_name: String,
|
||||
}
|
||||
|
||||
impl RustAction {
|
||||
pub fn new(plan: String, binary_name: String) -> Self {
|
||||
Self {
|
||||
plan,
|
||||
binary_name,
|
||||
config: RustActionConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute(
|
||||
&self,
|
||||
script: CuddleRustScript,
|
||||
variables: impl IntoIterator<Item = CuddleVariable>,
|
||||
) -> anyhow::Result<()> {
|
||||
let commit_sha = self
|
||||
.get_commit_sha()
|
||||
.await
|
||||
.context("failed to find a valid commit sha on the inferred path: .cuddle/plan")?;
|
||||
|
||||
let binary_hash = self.calculate_hash(commit_sha)?;
|
||||
|
||||
// Get cached binary
|
||||
// let binary = match self.get_binary(&binary_hash).await? {
|
||||
// Some(binary) => binary,
|
||||
// None => self.fetch_binary(&script, &binary_hash).await?,
|
||||
// };
|
||||
|
||||
// Execute binary
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_binary(
|
||||
&self,
|
||||
binary_hash: impl Into<String>,
|
||||
) -> anyhow::Result<Option<RustBinary>> {
|
||||
let binary_path = self.get_cached_binary_path(binary_hash);
|
||||
if !binary_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(RustBinary {}))
|
||||
}
|
||||
|
||||
fn get_cached_binary_path(&self, binary_hash: impl Into<String>) -> PathBuf {
|
||||
let cached_binary_name = self.get_cached_binary_name(binary_hash);
|
||||
let binary_path = self
|
||||
.config
|
||||
.cache_dir
|
||||
.join("binaries")
|
||||
.join(cached_binary_name);
|
||||
binary_path
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn get_cached_binary_name(&self, binary_hash: impl Into<String>) -> String {
|
||||
format!("{}-{}", binary_hash.into(), self.binary_name)
|
||||
}
|
||||
|
||||
async fn get_commit_sha(&self) -> anyhow::Result<String> {
|
||||
let repo = git2::Repository::open(".cuddle/plan")?;
|
||||
let head = repo.head()?;
|
||||
let commit = head.peel_to_commit()?;
|
||||
|
||||
let commit_sha = commit.id();
|
||||
|
||||
Ok(commit_sha.to_string())
|
||||
}
|
||||
|
||||
// async fn fetch_binary(
|
||||
// &self,
|
||||
// script: &CuddleRustScript,
|
||||
// binary_hash: impl Into<String>,
|
||||
// ) -> anyhow::Result<RustBinary> {
|
||||
//let upstream = &script.upstream;
|
||||
|
||||
//TODO: we should interpret some template variables in the upstream string. Ignore for now though
|
||||
|
||||
// match UpstreamRustBinary::from(upstream) {
|
||||
// UpstreamRustBinary::HttpBased { url } => {
|
||||
// let client = reqwest::ClientBuilder::new()
|
||||
// .user_agent(concat!(
|
||||
// env!("CARGO_PKG_NAME"),
|
||||
// "/",
|
||||
// env!("CARGO_PKG_VERSION")
|
||||
// ))
|
||||
// .connect_timeout(Duration::from_secs(5))
|
||||
// .build()?;
|
||||
|
||||
// let resp = client.request(Method::GET, url).send().await?;
|
||||
|
||||
// let mut stream = resp.bytes_stream();
|
||||
|
||||
// let mut file = File::create(self.get_cached_binary_name(binary_hash)).await?;
|
||||
|
||||
// while let Some(item) = stream.next().await {
|
||||
// let chunk = item?;
|
||||
|
||||
// file.write_all(&chunk).await?;
|
||||
// }
|
||||
|
||||
// // Make sure the entire file is written before we execute it
|
||||
// file.flush().await?;
|
||||
|
||||
// todo!()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
fn calculate_hash(&self, commit_sha: impl Into<Vec<u8>>) -> anyhow::Result<String> {
|
||||
let mut contents: Vec<u8> = Vec::new();
|
||||
contents.append(&mut self.plan.clone().into_bytes());
|
||||
contents.append(&mut commit_sha.into());
|
||||
|
||||
let hash = blake3::hash(&contents);
|
||||
|
||||
let hex = hash.to_hex();
|
||||
|
||||
Ok(hex.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct RustBinary {}
|
||||
|
||||
pub enum UpstreamRustBinary {
|
||||
HttpBased { url: String },
|
||||
}
|
||||
|
||||
impl From<CuddleRustUpstream> for UpstreamRustBinary {
|
||||
fn from(value: CuddleRustUpstream) -> Self {
|
||||
match value {
|
||||
CuddleRustUpstream::Gitea { url } => Self::HttpBased { url },
|
||||
}
|
||||
}
|
||||
}
|
||||
impl From<&CuddleRustUpstream> for UpstreamRustBinary {
|
||||
fn from(value: &CuddleRustUpstream) -> Self {
|
||||
match value {
|
||||
CuddleRustUpstream::Gitea { url } => Self::HttpBased { url: url.clone() },
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
62
cuddle/src/actions/shell.rs
Normal file
62
cuddle/src/actions/shell.rs
Normal file
@ -0,0 +1,62 @@
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::process::Stdio;
|
||||
use std::{env::current_dir, path::PathBuf, process::Command};
|
||||
|
||||
use crate::model::CuddleVariable;
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ShellAction {
|
||||
path: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl ShellAction {
|
||||
pub fn new(name: String, path: String) -> Self {
|
||||
Self { path, name }
|
||||
}
|
||||
|
||||
pub fn execute(self, variables: Vec<CuddleVariable>) -> anyhow::Result<()> {
|
||||
log::debug!("executing shell action: {}", self.path.clone());
|
||||
|
||||
log::info!("Starting running shell action: {}", self.path.clone());
|
||||
|
||||
let path = PathBuf::from(self.path.clone());
|
||||
if !path.exists() {
|
||||
log::info!("script='{}' not found, aborting", path.to_string_lossy());
|
||||
return Err(anyhow::anyhow!("file not found aborting"));
|
||||
}
|
||||
|
||||
let current_dir = current_dir()?;
|
||||
log::trace!("current executable dir={}", current_dir.to_string_lossy());
|
||||
|
||||
let mut process = Command::new(&path)
|
||||
.current_dir(current_dir)
|
||||
.envs(variables.iter().rev().map(|v| {
|
||||
log::trace!("{:?}", v);
|
||||
|
||||
(v.name.to_uppercase(), v.value.clone())
|
||||
}))
|
||||
.spawn()?;
|
||||
|
||||
let status = process.wait()?;
|
||||
|
||||
match status.code() {
|
||||
None => {
|
||||
log::warn!("process exited without code")
|
||||
}
|
||||
Some(n) => {
|
||||
if n > 0 {
|
||||
return Err(anyhow::anyhow!(
|
||||
"{} exited with: {}",
|
||||
path.clone().to_string_lossy(),
|
||||
n
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Finished running shell action");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
413
cuddle/src/cli/mod.rs
Normal file
413
cuddle/src/cli/mod.rs
Normal file
@ -0,0 +1,413 @@
|
||||
mod subcommands;
|
||||
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use clap::Command;
|
||||
|
||||
use crate::{
|
||||
actions::CuddleAction,
|
||||
config::{CuddleConfig, CuddleFetchPolicy},
|
||||
context::{CuddleContext, CuddleTreeType},
|
||||
model::*,
|
||||
util::git::GitCommit,
|
||||
};
|
||||
|
||||
use self::subcommands::{
|
||||
render::RenderCommand, render_kustomize::RenderKustomizeCommand,
|
||||
render_template::RenderTemplateCommand,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CuddleCli {
|
||||
scripts: Vec<CuddleAction>,
|
||||
variables: Vec<CuddleVariable>,
|
||||
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
|
||||
command: Option<Command>,
|
||||
tmp_dir: Option<PathBuf>,
|
||||
config: CuddleConfig,
|
||||
}
|
||||
|
||||
impl CuddleCli {
|
||||
pub fn new(
|
||||
context: Option<Arc<Mutex<Vec<CuddleContext>>>>,
|
||||
config: CuddleConfig,
|
||||
) -> anyhow::Result<CuddleCli> {
|
||||
let mut cli = CuddleCli {
|
||||
scripts: vec![],
|
||||
variables: vec![],
|
||||
context: context.clone(),
|
||||
command: None,
|
||||
tmp_dir: None,
|
||||
config,
|
||||
};
|
||||
|
||||
if let Ok(provider) = std::env::var("CUDDLE_SECRETS_PROVIDER") {
|
||||
let provider = provider
|
||||
.split(",")
|
||||
.map(|p| p.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
tracing::trace!("secrets-provider enabled, handling for each entry");
|
||||
handle_providers(provider)?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
|
||||
match context {
|
||||
Some(_) => {
|
||||
tracing::debug!("build full cli");
|
||||
cli = cli
|
||||
.process_variables()
|
||||
.process_scripts()
|
||||
.process_templates()?
|
||||
.build_cli();
|
||||
}
|
||||
None => {
|
||||
tracing::debug!("build bare cli");
|
||||
cli = cli.build_bare_cli();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
fn process_variables(mut self) -> Self {
|
||||
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
|
||||
for ctx in context_iter.iter() {
|
||||
if let Some(variables) = ctx.plan.vars.clone() {
|
||||
let mut variables: Vec<CuddleVariable> = variables.into();
|
||||
self.variables.append(&mut variables);
|
||||
}
|
||||
|
||||
if let CuddleTreeType::Root = ctx.node_type {
|
||||
let mut temp_path = ctx.path.clone();
|
||||
temp_path.push(".cuddle/tmp");
|
||||
|
||||
self.variables.push(CuddleVariable::new(
|
||||
"tmp",
|
||||
temp_path.clone().to_string_lossy().to_string(),
|
||||
));
|
||||
|
||||
self.tmp_dir = Some(temp_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match GitCommit::new() {
|
||||
Ok(commit) => self
|
||||
.variables
|
||||
.push(CuddleVariable::new("commit_sha", commit.commit_sha.clone())),
|
||||
Err(e) => {
|
||||
log::debug!("{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn process_scripts(mut self) -> Self {
|
||||
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
|
||||
for ctx in context_iter.iter() {
|
||||
if let Some(scripts) = ctx.plan.scripts.clone() {
|
||||
for (name, script) in scripts {
|
||||
match &script {
|
||||
CuddleScript::Shell(shell_script) => {
|
||||
self.scripts.push(CuddleAction::new(
|
||||
script.clone(),
|
||||
ctx.path.clone(),
|
||||
name,
|
||||
shell_script.description.clone(),
|
||||
))
|
||||
}
|
||||
CuddleScript::Dagger(_) => todo!(),
|
||||
CuddleScript::Lua(l) => self.scripts.push(CuddleAction::new(
|
||||
script.clone(),
|
||||
ctx.path.clone(),
|
||||
name,
|
||||
l.description.clone(),
|
||||
)),
|
||||
CuddleScript::Rust(_) => todo!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn process_templates(self) -> anyhow::Result<Self> {
|
||||
if self.tmp_dir.is_none() {
|
||||
log::debug!("cannot process template as bare bones cli");
|
||||
return Ok(self);
|
||||
}
|
||||
|
||||
// Make sure tmp_dir exists and clean it up first
|
||||
let tmp_dir = self
|
||||
.tmp_dir
|
||||
.clone()
|
||||
.ok_or(anyhow::anyhow!("tmp_dir does not exist aborting"))?;
|
||||
match self.config.get_fetch_policy()? {
|
||||
CuddleFetchPolicy::Always if tmp_dir.exists() && tmp_dir.ends_with("tmp") => {
|
||||
std::fs::remove_dir_all(tmp_dir.clone())?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(tmp_dir.clone())?;
|
||||
// Handle all templating with variables and such.
|
||||
// TODO: use actual templating engine, for new we just copy templates to the final folder
|
||||
|
||||
if let Ok(context_iter) = self.context.clone().unwrap().lock() {
|
||||
for ctx in context_iter.iter() {
|
||||
let mut template_path = ctx.path.clone();
|
||||
template_path.push("templates");
|
||||
|
||||
log::trace!("template path: {}", template_path.clone().to_string_lossy());
|
||||
|
||||
if !template_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for file in std::fs::read_dir(&template_path)? {
|
||||
let f = file?;
|
||||
let mut dest_file = tmp_dir.clone();
|
||||
dest_file.push(f.path().strip_prefix(&template_path)?.parent().unwrap());
|
||||
|
||||
tracing::trace!(
|
||||
"moving from: {} to {}",
|
||||
f.path().display(),
|
||||
dest_file.display()
|
||||
);
|
||||
|
||||
if f.path().is_dir() {
|
||||
std::fs::create_dir_all(&dest_file)?;
|
||||
}
|
||||
|
||||
fs_extra::copy_items(
|
||||
&[f.path()],
|
||||
&dest_file,
|
||||
&fs_extra::dir::CopyOptions {
|
||||
overwrite: true,
|
||||
skip_exist: false,
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
fn build_cli(mut self) -> Self {
|
||||
let mut root_cmd = Command::new("cuddle")
|
||||
.version("1.0")
|
||||
.author("kjuulh <contact@kasperhermansen.com>")
|
||||
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
|
||||
.subcommand_required(true)
|
||||
.arg_required_else_help(true)
|
||||
.propagate_version(true)
|
||||
.arg(clap::Arg::new("secrets-provider").long("secrets-provider").env("CUDDLE_SECRETS_PROVIDER"));
|
||||
|
||||
root_cmd = subcommands::x::build_command(root_cmd, self.clone());
|
||||
root_cmd = subcommands::render_template::build_command(root_cmd);
|
||||
root_cmd = subcommands::render_kustomize::build_command(root_cmd);
|
||||
root_cmd = subcommands::render::build_command(root_cmd);
|
||||
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
|
||||
|
||||
self.command = Some(root_cmd);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn build_bare_cli(mut self) -> Self {
|
||||
let mut root_cmd = Command::new("cuddle")
|
||||
.version("1.0")
|
||||
.author("kjuulh <contact@kasperhermansen.com>")
|
||||
.about("cuddle is your domain specific organization tool. It enabled widespread sharing through repositories, as well as collaborating while maintaining speed and integrity")
|
||||
.subcommand_required(true)
|
||||
.arg_required_else_help(true)
|
||||
.propagate_version(true);
|
||||
|
||||
root_cmd = subcommands::init::build_command(root_cmd, self.clone());
|
||||
|
||||
self.command = Some(root_cmd);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn execute(self) -> anyhow::Result<Self> {
|
||||
if let Some(cli) = self.command.clone() {
|
||||
let matches = cli.clone().get_matches();
|
||||
|
||||
let res = match matches.subcommand() {
|
||||
Some(("x", exe_submatch)) => subcommands::x::execute_x(exe_submatch, self.clone()),
|
||||
Some(("render", sub_matches)) => {
|
||||
RenderCommand::execute(sub_matches, self.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
Some(("render_template", sub_matches)) => {
|
||||
RenderTemplateCommand::from_matches(sub_matches, self.clone())
|
||||
.and_then(|cmd| cmd.execute())?;
|
||||
Ok(())
|
||||
}
|
||||
Some(("render-kustomize", sub_matches)) => {
|
||||
RenderKustomizeCommand::from_matches(sub_matches, self.clone())
|
||||
.and_then(|cmd| cmd.execute())?;
|
||||
Ok(())
|
||||
}
|
||||
Some(("init", sub_matches)) => {
|
||||
subcommands::init::execute_init(sub_matches, self.clone())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("could not find a match")),
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum SecretProvider {
|
||||
OnePassword {
|
||||
inject: Vec<String>,
|
||||
dotenv: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TryFrom<String> for SecretProvider {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(value: String) -> Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"1password" => {
|
||||
let one_password_inject = std::env::var("CUDDLE_ONE_PASSWORD_INJECT")
|
||||
.ok()
|
||||
.filter(|f| f.as_str() != "");
|
||||
let one_password_dot_env = std::env::var("CUDDLE_ONE_PASSWORD_DOT_ENV").ok();
|
||||
|
||||
let injectables = one_password_inject
|
||||
.unwrap_or(String::new())
|
||||
.split(",")
|
||||
.filter(|s| s.contains('='))
|
||||
.map(|i| i.to_string())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// for i in &injectables {
|
||||
// if !std::path::PathBuf::from(i).exists() {
|
||||
// anyhow::bail!("1pass injectable path doesn't exist: {}", i);
|
||||
// }
|
||||
// }
|
||||
if let Some(one_password_dot_env) = &one_password_dot_env {
|
||||
if let Ok(dir) = std::env::current_dir() {
|
||||
tracing::trace!(
|
||||
current_dir = dir.display().to_string(),
|
||||
dotenv = &one_password_dot_env,
|
||||
exists = PathBuf::from(&one_password_dot_env).exists(),
|
||||
"1password dotenv inject"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self::OnePassword {
|
||||
inject: injectables,
|
||||
dotenv: if let Some(one_password_dot_env) = one_password_dot_env {
|
||||
if PathBuf::from(&one_password_dot_env).exists() {
|
||||
Some(one_password_dot_env)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
}
|
||||
value => {
|
||||
tracing::debug!(
|
||||
"provided secrets manager doesn't match any allowed values {}",
|
||||
value
|
||||
);
|
||||
Err(anyhow::anyhow!("value is not one of supported values"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_providers(provider: Vec<String>) -> anyhow::Result<()> {
|
||||
fn execute_1password(lookup: &str) -> anyhow::Result<String> {
|
||||
let out = std::process::Command::new("op")
|
||||
.arg("read")
|
||||
.arg(lookup)
|
||||
.output()?;
|
||||
let secret = std::str::from_utf8(&out.stdout)?;
|
||||
Ok(secret.to_string())
|
||||
}
|
||||
|
||||
fn execute_1password_inject(file: &str) -> anyhow::Result<Vec<(String, String)>> {
|
||||
let out = std::process::Command::new("op")
|
||||
.arg("inject")
|
||||
.arg("--in-file")
|
||||
.arg(file)
|
||||
.output()?;
|
||||
let secrets = std::str::from_utf8(&out.stdout)?.split('\n');
|
||||
let secrets_pair = secrets
|
||||
.map(|secrets_pair| secrets_pair.split_once("="))
|
||||
.flatten()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect::<Vec<(String, String)>>();
|
||||
|
||||
Ok(secrets_pair)
|
||||
}
|
||||
|
||||
let res = provider
|
||||
.into_iter()
|
||||
.map(|p| SecretProvider::try_from(p))
|
||||
.collect::<anyhow::Result<Vec<_>>>();
|
||||
let res = res?;
|
||||
|
||||
let res = res
|
||||
.into_iter()
|
||||
.map(|p| match p {
|
||||
SecretProvider::OnePassword { inject, dotenv } => {
|
||||
tracing::trace!(
|
||||
inject = inject.join(","),
|
||||
dotenv = dotenv,
|
||||
"handling 1password"
|
||||
);
|
||||
if let Some(dotenv) = dotenv {
|
||||
let pairs = execute_1password_inject(&dotenv).unwrap();
|
||||
for (key, value) in pairs {
|
||||
tracing::debug!(env_name = &key, value=&value, "set var from 1password");
|
||||
std::env::set_var(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
for i in inject {
|
||||
let (env_var_name, op_lookup) = i.split_once("=").ok_or(anyhow::anyhow!(
|
||||
"CUDDLE_ONE_PASSWORD_INJECT is not a key value pair ie. key:value,key2=value2"
|
||||
))?;
|
||||
let secret = execute_1password(&op_lookup)?;
|
||||
std::env::set_var(&env_var_name, secret);
|
||||
tracing::debug!(
|
||||
env_name = &env_var_name,
|
||||
lookup = &op_lookup,
|
||||
"set var from 1password"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.collect::<anyhow::Result<Vec<()>>>();
|
||||
|
||||
let _ = res?;
|
||||
|
||||
Ok(())
|
||||
}
|
169
cuddle/src/cli/subcommands/folder.rs
Normal file
169
cuddle/src/cli/subcommands/folder.rs
Normal file
@ -0,0 +1,169 @@
|
||||
use std::{collections::HashMap, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Arg, ArgAction, ArgMatches, Command};
|
||||
use serde_json::from_value;
|
||||
use tera::Function;
|
||||
|
||||
use crate::{cli::CuddleCli, model::CuddleVariable};
|
||||
|
||||
pub fn build_command(root_cmd: Command) -> Command {
|
||||
root_cmd.subcommand(
|
||||
Command::new("folder")
|
||||
.about("renders a template folder")
|
||||
.args(&[
|
||||
Arg::new("source")
|
||||
.long("source")
|
||||
.required(true)
|
||||
.value_parser(clap::value_parser!(PathBuf)),
|
||||
Arg::new("destination")
|
||||
.long("destination")
|
||||
.required(true)
|
||||
.value_parser(clap::value_parser!(PathBuf)),
|
||||
Arg::new("extra-var")
|
||||
.long("extra-var")
|
||||
.action(ArgAction::Append)
|
||||
.required(false),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct FolderCommand {
|
||||
variables: Vec<CuddleVariable>,
|
||||
source: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
impl FolderCommand {
|
||||
pub fn from_matches(matches: &ArgMatches, cli: CuddleCli) -> anyhow::Result<Self> {
|
||||
let source = matches
|
||||
.get_one::<PathBuf>("source")
|
||||
.expect("source")
|
||||
.clone();
|
||||
|
||||
let destination = matches
|
||||
.get_one::<PathBuf>("destination")
|
||||
.expect("destination")
|
||||
.clone();
|
||||
|
||||
let mut extra_vars: Vec<CuddleVariable> =
|
||||
if let Some(extra_vars) = matches.get_many::<String>("extra-var") {
|
||||
let mut vars = Vec::with_capacity(extra_vars.len());
|
||||
for var in extra_vars.into_iter() {
|
||||
let parts: Vec<&str> = var.split('=').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!("extra-var: is not set correctly: {}", var));
|
||||
}
|
||||
|
||||
vars.push(CuddleVariable::new(parts[0], parts[1]));
|
||||
}
|
||||
vars
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
extra_vars.append(&mut cli.variables.clone());
|
||||
|
||||
Ok(Self {
|
||||
variables: extra_vars,
|
||||
source,
|
||||
destination,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute(self) -> anyhow::Result<()> {
|
||||
let _ = std::fs::remove_dir_all(&self.destination);
|
||||
std::fs::create_dir_all(&self.destination).context("failed to create directory")?;
|
||||
|
||||
// Prepare context
|
||||
let mut context = tera::Context::new();
|
||||
for var in self.variables.iter().rev() {
|
||||
context.insert(var.name.to_lowercase().replace([' ', '-'], "_"), &var.value)
|
||||
}
|
||||
|
||||
let mut tera = tera::Tera::default();
|
||||
|
||||
tera.register_function("filter_by_prefix", filter_by_prefix(self.variables.clone()));
|
||||
|
||||
for entry in walkdir::WalkDir::new(&self.source) {
|
||||
let entry = entry.context("entry was not found")?;
|
||||
let entry_path = entry.path();
|
||||
let rel_path = self
|
||||
.destination
|
||||
.join(entry_path.strip_prefix(&self.source)?);
|
||||
|
||||
if entry_path.is_file() {
|
||||
// Load source template
|
||||
let source = std::fs::read_to_string(entry_path)
|
||||
.context("failed to read entry into memory")?;
|
||||
|
||||
let output = tera.render_str(&source, &context)?;
|
||||
|
||||
if let Some(parent) = rel_path.parent() {
|
||||
std::fs::create_dir_all(parent).context("failed to create parent dir")?;
|
||||
}
|
||||
|
||||
// Put template in final destination
|
||||
std::fs::write(&rel_path, output).context(format!(
|
||||
"failed to write to destination: {}",
|
||||
&rel_path.display()
|
||||
))?;
|
||||
|
||||
log::info!("finished writing template to: {}", &rel_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn filter_by_prefix(variables: Vec<CuddleVariable>) -> impl Function {
|
||||
Box::new(
|
||||
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
|
||||
for var in &variables {
|
||||
tracing::info!("variable: {} - {}", var.name, var.value);
|
||||
}
|
||||
|
||||
let prefix = match args.get("prefix") {
|
||||
Some(value) => match from_value::<Vec<String>>(value.clone()) {
|
||||
Ok(prefix) => prefix,
|
||||
Err(e) => {
|
||||
tracing::error!("prefix was not a string: {}", e);
|
||||
return Err("prefix was not a string".into());
|
||||
}
|
||||
},
|
||||
None => return Err("prefix is required".into()),
|
||||
};
|
||||
|
||||
let prefix = prefix.join("_");
|
||||
|
||||
let vars = variables
|
||||
.iter()
|
||||
.filter_map(|v| {
|
||||
if v.name.starts_with(&prefix) {
|
||||
Some(CuddleVariable::new(
|
||||
v.name.trim_start_matches(&prefix).trim_start_matches('_'),
|
||||
v.value.clone(),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<CuddleVariable>>();
|
||||
|
||||
let mut structure: HashMap<String, String> = HashMap::new();
|
||||
for var in vars {
|
||||
if !structure.contains_key(&var.name) {
|
||||
tracing::info!("found: {} - {}", &var.name, &var.value);
|
||||
structure.insert(var.name, var.value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::to_value(structure).unwrap())
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn filter_by_name(args: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
|
||||
Ok(tera::Value::Null)
|
||||
}
|
227
cuddle/src/cli/subcommands/init.rs
Normal file
227
cuddle/src/cli/subcommands/init.rs
Normal file
@ -0,0 +1,227 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs::{create_dir_all, read, read_dir};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{ArgMatches, Command};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::cli::CuddleCli;
|
||||
|
||||
pub fn build_command(root_cmd: Command, _cli: CuddleCli) -> Command {
|
||||
let mut repo_url = clap::Arg::new("repo").long("repo").short('r');
|
||||
|
||||
if let Ok(cuddle_template_url) = std::env::var("CUDDLE_TEMPLATE_URL") {
|
||||
repo_url = repo_url.default_value(cuddle_template_url);
|
||||
} else {
|
||||
repo_url = repo_url.required(true);
|
||||
}
|
||||
|
||||
let execute_cmd = Command::new("init")
|
||||
.about("init bootstraps a repository from a template")
|
||||
.arg(repo_url)
|
||||
.arg(clap::Arg::new("name").long("name"))
|
||||
.arg(clap::Arg::new("path").long("path"))
|
||||
.arg(clap::Arg::new("value").short('v').long("value"));
|
||||
|
||||
root_cmd.subcommand(execute_cmd)
|
||||
}
|
||||
|
||||
pub fn execute_init(exe_submatch: &ArgMatches, _cli: CuddleCli) -> anyhow::Result<()> {
|
||||
let repo = exe_submatch.get_one::<String>("repo").unwrap();
|
||||
let name = exe_submatch.get_one::<String>("name");
|
||||
let path = exe_submatch.get_one::<String>("path");
|
||||
let values = exe_submatch
|
||||
.get_many::<String>("value")
|
||||
.unwrap_or_default()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tracing::info!("Downloading: {}", repo);
|
||||
|
||||
create_dir_all(std::env::temp_dir())?;
|
||||
|
||||
let tmpdir = tempfile::tempdir()?;
|
||||
|
||||
let tmpdir_path = tmpdir.path().canonicalize()?;
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&["clone", repo, "."])
|
||||
.current_dir(tmpdir_path)
|
||||
.output()?;
|
||||
|
||||
std::io::stdout().write_all(&output.stdout)?;
|
||||
std::io::stderr().write_all(&output.stderr)?;
|
||||
|
||||
let templates_path = tmpdir.path().join("cuddle-templates.json");
|
||||
let template_path = tmpdir.path().join("cuddle-template.json");
|
||||
let templates = if templates_path.exists() {
|
||||
let templates = read(templates_path)?;
|
||||
let templates: CuddleTemplates = serde_json::from_slice(&templates)?;
|
||||
|
||||
let mut single_templates = Vec::new();
|
||||
|
||||
for template_name in templates.templates.iter() {
|
||||
let template = read(
|
||||
tmpdir
|
||||
.path()
|
||||
.join(template_name)
|
||||
.join("cuddle-template.json"),
|
||||
)?;
|
||||
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
|
||||
|
||||
single_templates.push((template_name, template))
|
||||
}
|
||||
|
||||
single_templates
|
||||
.into_iter()
|
||||
.map(|(name, template)| (name.clone(), tmpdir.path().join(name), template))
|
||||
.collect::<Vec<_>>()
|
||||
} else if template_path.exists() {
|
||||
let template = read(template_path)?;
|
||||
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
|
||||
vec![(template.clone().name, tmpdir.path().to_path_buf(), template)]
|
||||
} else {
|
||||
anyhow::bail!("No cuddle-template.json or cuddle-templates.json found");
|
||||
};
|
||||
|
||||
let template = match name {
|
||||
Some(name) => {
|
||||
let template = read(tmpdir.path().join(name).join("cuddle-template.json"))?;
|
||||
let template = serde_json::from_slice::<CuddleTemplate>(&template)?;
|
||||
Ok((name.clone(), tmpdir.path().join(name), template))
|
||||
}
|
||||
None => {
|
||||
if templates.len() > 1 {
|
||||
let name = inquire::Select::new(
|
||||
"template",
|
||||
templates.iter().map(|t| t.0.clone()).collect(),
|
||||
)
|
||||
.with_help_message("name of which template to use")
|
||||
.prompt()?;
|
||||
|
||||
let found_template = templates
|
||||
.iter()
|
||||
.find(|item| item.0 == name)
|
||||
.ok_or(anyhow::anyhow!("could not find an item with that name"))?;
|
||||
|
||||
Ok(found_template.clone())
|
||||
} else if templates.len() == 1 {
|
||||
Ok(templates[0].clone())
|
||||
} else {
|
||||
Err(anyhow::anyhow!("No templates found, with any valid names"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (_name, template_dir, mut template) = template?;
|
||||
|
||||
let path = match path {
|
||||
Some(path) => path.clone(),
|
||||
None => inquire::Text::new("path")
|
||||
.with_help_message("to where it should be placed")
|
||||
.with_default(".")
|
||||
.prompt()?,
|
||||
};
|
||||
|
||||
create_dir_all(&path)?;
|
||||
let dir = std::fs::read_dir(&path)?;
|
||||
if dir.count() != 0 {
|
||||
for entry in read_dir(&path)? {
|
||||
let entry = entry?;
|
||||
if entry.file_name() == ".git" {
|
||||
continue;
|
||||
} else {
|
||||
anyhow::bail!("Directory {} is not empty", &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(ref mut prompt) = template.prompt {
|
||||
'prompt: for (name, prompt) in prompt {
|
||||
for value in &values {
|
||||
if let Some((value_name, value_content)) = value.split_once("=") {
|
||||
if value_name == name {
|
||||
prompt.value = value_content.to_string();
|
||||
continue 'prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
let value = inquire::Text::new(&name)
|
||||
.with_help_message(&prompt.description)
|
||||
.prompt()?;
|
||||
prompt.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(&template_dir).follow_links(false) {
|
||||
let entry = entry?;
|
||||
let entry_path = entry.path();
|
||||
|
||||
let new_path = PathBuf::from(&path).join(entry_path.strip_prefix(&template_dir)?);
|
||||
let new_path = replace_with_variables(&new_path.to_string_lossy().to_string(), &template)?;
|
||||
let new_path = PathBuf::from(new_path);
|
||||
|
||||
if entry_path.is_dir() {
|
||||
create_dir_all(&new_path)?;
|
||||
}
|
||||
|
||||
if entry_path.is_file() {
|
||||
let name = entry.file_name();
|
||||
if let Some(parent) = entry_path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
if name == "cuddle-template.json" || name == "cuddle-templates.json" {
|
||||
continue;
|
||||
}
|
||||
|
||||
tracing::info!("writing to: {}", new_path.display());
|
||||
|
||||
let old_content = match std::fs::read_to_string(entry_path) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::debug!("found invalid file possibly with invalid utf8: {}", e);
|
||||
std::fs::copy(entry_path, new_path).context("failed to write file")?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let new_content = replace_with_variables(&old_content, &template)?;
|
||||
|
||||
std::fs::write(new_path, new_content.as_bytes())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn replace_with_variables(content: &str, template: &CuddleTemplate) -> anyhow::Result<String> {
|
||||
let mut content = content.to_string();
|
||||
if let Some(prompt) = &template.prompt {
|
||||
for (name, value) in prompt {
|
||||
content = content.replace(&format!("%%{}%%", name), &value.value);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct CuddleTemplates {
|
||||
pub templates: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct CuddleTemplate {
|
||||
pub name: String,
|
||||
pub prompt: Option<BTreeMap<String, CuddleTemplatePrompt>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct CuddleTemplatePrompt {
|
||||
pub description: String,
|
||||
#[serde(skip)]
|
||||
pub value: String,
|
||||
}
|
91
cuddle/src/cli/subcommands/kustomize.rs
Normal file
91
cuddle/src/cli/subcommands/kustomize.rs
Normal file
@ -0,0 +1,91 @@
|
||||
use std::{io::Write, path::PathBuf};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
|
||||
use crate::cli::CuddleCli;
|
||||
|
||||
pub fn build_command(root_cmd: Command) -> Command {
|
||||
root_cmd.subcommand(
|
||||
Command::new("kustomize")
|
||||
.about("renders a kustomize folder")
|
||||
.args(&[
|
||||
Arg::new("kustomize-folder")
|
||||
.long("kustomize-folder")
|
||||
.value_parser(clap::value_parser!(PathBuf))
|
||||
.required(true),
|
||||
Arg::new("destination")
|
||||
.long("destination")
|
||||
.required(true)
|
||||
.value_parser(clap::value_parser!(PathBuf)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct KustomizeCommand {
|
||||
kustomize_folder: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
impl KustomizeCommand {
|
||||
pub fn from_matches(matches: &ArgMatches, _cli: CuddleCli) -> anyhow::Result<Self> {
|
||||
let kustomize_folder = matches
|
||||
.get_one::<PathBuf>("kustomize-folder")
|
||||
.expect("kustomize-folder")
|
||||
.clone();
|
||||
|
||||
let destination = matches
|
||||
.get_one::<PathBuf>("destination")
|
||||
.expect("destination")
|
||||
.clone();
|
||||
|
||||
Ok(Self {
|
||||
kustomize_folder,
|
||||
destination,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute(self) -> anyhow::Result<()> {
|
||||
let mut cmd = std::process::Command::new("kubectl");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&self.destination);
|
||||
std::fs::create_dir_all(&self.destination)?;
|
||||
|
||||
let cmd = cmd
|
||||
.arg("kustomize")
|
||||
.arg("--enable-helm")
|
||||
.arg(self.kustomize_folder);
|
||||
let output = cmd.output().context("failed to run kubectl kustomize")?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"failed to run kustomize: {}",
|
||||
output.status.code().expect("to find exit code")
|
||||
)
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new("kubectl-slice");
|
||||
let cmd = cmd
|
||||
.arg("-o")
|
||||
.arg(self.destination)
|
||||
.stdin(std::process::Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn().context("failed to run kubectl-slice")?;
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(&output.stdout)?;
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.context("failed to run kubectl-slice")?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"failed to run kustomize: {}",
|
||||
output.status.code().expect("to find exit code")
|
||||
)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
7
cuddle/src/cli/subcommands/mod.rs
Normal file
7
cuddle/src/cli/subcommands/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod folder;
|
||||
pub mod init;
|
||||
pub mod kustomize;
|
||||
pub mod render;
|
||||
pub mod render_kustomize;
|
||||
pub mod render_template;
|
||||
pub mod x;
|
34
cuddle/src/cli/subcommands/render.rs
Normal file
34
cuddle/src/cli/subcommands/render.rs
Normal file
@ -0,0 +1,34 @@
|
||||
use anyhow::Context;
|
||||
use clap::{ArgMatches, Command};
|
||||
|
||||
use crate::cli::CuddleCli;
|
||||
|
||||
use super::{folder::FolderCommand, kustomize::KustomizeCommand};
|
||||
|
||||
pub fn build_command(root_cmd: Command) -> Command {
|
||||
let cmd = Command::new("render").about("accesses different render commands");
|
||||
|
||||
let cmd = super::kustomize::build_command(cmd);
|
||||
let cmd = super::folder::build_command(cmd);
|
||||
|
||||
root_cmd.subcommand(cmd)
|
||||
}
|
||||
|
||||
pub struct RenderCommand {}
|
||||
impl RenderCommand {
|
||||
pub fn execute(matches: &ArgMatches, cli: CuddleCli) -> anyhow::Result<()> {
|
||||
match matches.subcommand() {
|
||||
Some(("kustomize", sub_matches)) => {
|
||||
KustomizeCommand::from_matches(sub_matches, cli)?.execute()?;
|
||||
}
|
||||
Some(("folder", sub_matches)) => {
|
||||
FolderCommand::from_matches(sub_matches, cli)?
|
||||
.execute()
|
||||
.context("failed to render folder")?;
|
||||
}
|
||||
_ => anyhow::bail!("failed to find match for render"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
70
cuddle/src/cli/subcommands/render_kustomize.rs
Normal file
70
cuddle/src/cli/subcommands/render_kustomize.rs
Normal file
@ -0,0 +1,70 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
|
||||
use crate::cli::CuddleCli;
|
||||
|
||||
pub fn build_command(root_cmd: Command) -> Command {
|
||||
root_cmd.subcommand(
|
||||
Command::new("render-kustomize")
|
||||
.about("renders a kustomize folder")
|
||||
.args(&[
|
||||
Arg::new("kustomize-folder")
|
||||
.long("kustomize-folder")
|
||||
.value_parser(clap::value_parser!(PathBuf))
|
||||
.required(true),
|
||||
Arg::new("destination")
|
||||
.long("destination")
|
||||
.required(true)
|
||||
.value_parser(clap::value_parser!(PathBuf)),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct RenderKustomizeCommand {
|
||||
kustomize_folder: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
impl RenderKustomizeCommand {
|
||||
pub fn from_matches(matches: &ArgMatches, _cli: CuddleCli) -> anyhow::Result<Self> {
|
||||
let kustomize_folder = matches
|
||||
.get_one::<PathBuf>("kustomize-folder")
|
||||
.expect("kustomize-folder")
|
||||
.clone();
|
||||
|
||||
let destination = matches
|
||||
.get_one::<PathBuf>("destination")
|
||||
.expect("destination")
|
||||
.clone();
|
||||
|
||||
Ok(Self {
|
||||
kustomize_folder,
|
||||
destination,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute(self) -> anyhow::Result<()> {
|
||||
let mut cmd = std::process::Command::new("kubectl");
|
||||
|
||||
std::fs::create_dir_all(&self.destination)?;
|
||||
|
||||
let cmd = cmd
|
||||
.arg("kustomize")
|
||||
.arg(self.kustomize_folder)
|
||||
.arg(format!("--output={}", self.destination.display()));
|
||||
|
||||
let mut process = cmd.spawn()?;
|
||||
|
||||
let exit = process.wait()?;
|
||||
|
||||
if !exit.success() {
|
||||
anyhow::bail!(
|
||||
"failed to run kustomize: {}",
|
||||
exit.code().expect("to find exit code")
|
||||
)
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
153
cuddle/src/cli/subcommands/render_template.rs
Normal file
153
cuddle/src/cli/subcommands/render_template.rs
Normal file
@ -0,0 +1,153 @@
|
||||
use std::{path::PathBuf, str::FromStr};
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
|
||||
use crate::{cli::CuddleCli, model::CuddleVariable};
|
||||
|
||||
const DESTINATION: &str = "destination is the output path of the template once done, but default .tmpl is stripped and the normal file extension is used. this can be overwritten if a file path is entered instead. I.e. (/some/file/name.txt)";
|
||||
const TEMPLATE_FILE: &str = "template-file is the input file path of the .tmpl file (or inferred) that you would like to render";
|
||||
|
||||
pub fn build_command(root_cmd: Command) -> Command {
|
||||
root_cmd.subcommand(
|
||||
Command::new("render_template")
|
||||
.about("renders a jinja compatible template")
|
||||
.args(&[
|
||||
Arg::new("template-file")
|
||||
.alias("template")
|
||||
.short('t')
|
||||
.long("template-file")
|
||||
.required(true)
|
||||
.action(clap::ArgAction::Set)
|
||||
.long_help(TEMPLATE_FILE),
|
||||
Arg::new("destination")
|
||||
.alias("dest")
|
||||
.short('d')
|
||||
.long("destination")
|
||||
.required(true)
|
||||
.action(clap::ArgAction::Set)
|
||||
.long_help(DESTINATION),
|
||||
Arg::new("extra-var")
|
||||
.long("extra-var")
|
||||
.required(false)
|
||||
.action(clap::ArgAction::Append),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct RenderTemplateCommand {
|
||||
variables: Vec<CuddleVariable>,
|
||||
template_file: PathBuf,
|
||||
destination: PathBuf,
|
||||
}
|
||||
|
||||
impl RenderTemplateCommand {
|
||||
pub fn from_matches(matches: &ArgMatches, cli: CuddleCli) -> anyhow::Result<Self> {
|
||||
let template_file = matches
|
||||
.get_one::<String>("template-file")
|
||||
.ok_or(anyhow::anyhow!("template-file was not found"))
|
||||
.and_then(get_path_buf_and_check_exists)?;
|
||||
|
||||
let destination = matches
|
||||
.get_one::<String>("destination")
|
||||
.ok_or(anyhow::anyhow!("destination was not found"))
|
||||
.and_then(get_path_buf_and_check_dir_exists)
|
||||
.and_then(RenderTemplateCommand::transform_extension)
|
||||
.context("failed to access dest directory")?;
|
||||
|
||||
let mut extra_vars: Vec<CuddleVariable> =
|
||||
if let Some(extra_vars) = matches.get_many::<String>("extra-var") {
|
||||
let mut vars = Vec::with_capacity(extra_vars.len());
|
||||
for var in extra_vars.into_iter() {
|
||||
let parts: Vec<&str> = var.split('=').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(anyhow::anyhow!("extra-var: is not set correctly: {}", var));
|
||||
}
|
||||
|
||||
vars.push(CuddleVariable::new(parts[0], parts[1]));
|
||||
}
|
||||
vars
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
extra_vars.append(&mut cli.variables.clone());
|
||||
|
||||
Ok(Self {
|
||||
variables: extra_vars,
|
||||
template_file,
|
||||
destination,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn execute(self) -> anyhow::Result<()> {
|
||||
// Prepare context
|
||||
let mut context = tera::Context::new();
|
||||
for var in self.variables {
|
||||
context.insert(var.name.to_lowercase().replace([' ', '-'], "_"), &var.value)
|
||||
}
|
||||
|
||||
// Load source template
|
||||
let source = std::fs::read_to_string(self.template_file)?;
|
||||
|
||||
let output = tera::Tera::one_off(source.as_str(), &context, false)?;
|
||||
|
||||
if let Some(parent) = self.destination.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Put template in final destination
|
||||
std::fs::write(&self.destination, output).context(format!(
|
||||
"failed to write to destination: {}",
|
||||
&self.destination.display(),
|
||||
))?;
|
||||
|
||||
log::info!(
|
||||
"finished writing template to: {}",
|
||||
&self.destination.to_string_lossy()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn transform_extension(template_path: PathBuf) -> anyhow::Result<PathBuf> {
|
||||
if template_path.is_file() {
|
||||
let ext = template_path.extension().ok_or(anyhow::anyhow!(
|
||||
"destination path does not have an extension"
|
||||
))?;
|
||||
if ext.to_string_lossy().ends_with("tmpl") {
|
||||
let template_dest = template_path
|
||||
.to_str()
|
||||
.and_then(|s| s.strip_suffix(".tmpl"))
|
||||
.ok_or(anyhow::anyhow!("string does not end in .tmpl"))?;
|
||||
|
||||
return PathBuf::from_str(template_dest).map_err(|e| anyhow::anyhow!(e));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(template_path)
|
||||
}
|
||||
}
|
||||
|
||||
fn get_path_buf_and_check_exists(raw_path: impl Into<String>) -> anyhow::Result<PathBuf> {
|
||||
match PathBuf::from_str(&raw_path.into()) {
|
||||
Ok(pb) => {
|
||||
if pb.exists() {
|
||||
Ok(pb)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"path: {}, could not be found",
|
||||
pb.to_string_lossy()
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_path_buf_and_check_dir_exists(raw_path: impl Into<String>) -> anyhow::Result<PathBuf> {
|
||||
match PathBuf::from_str(&raw_path.into()) {
|
||||
Ok(pb) => Ok(pb),
|
||||
Err(e) => Err(anyhow::anyhow!(e)),
|
||||
}
|
||||
}
|
89
cuddle/src/cli/subcommands/x.rs
Normal file
89
cuddle/src/cli/subcommands/x.rs
Normal file
@ -0,0 +1,89 @@
|
||||
use clap::{Arg, ArgMatches, Command};
|
||||
|
||||
use crate::cli::CuddleCli;
|
||||
|
||||
pub fn build_command(root_cmd: Command, cli: CuddleCli) -> Command {
|
||||
if cli.scripts.len() > 0 {
|
||||
let execute_cmd_about = "x is your entry into your domains scripts, scripts inherited from parents will also be present here";
|
||||
let mut execute_cmd = Command::new("x")
|
||||
.about(execute_cmd_about)
|
||||
.subcommand_required(true);
|
||||
|
||||
execute_cmd = execute_cmd.subcommands(&build_scripts(cli));
|
||||
|
||||
root_cmd.subcommand(execute_cmd)
|
||||
} else {
|
||||
root_cmd
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_scripts(cli: CuddleCli) -> Vec<Command> {
|
||||
let mut cmds = Vec::new();
|
||||
for script in cli.scripts.iter() {
|
||||
let mut cmd = Command::new(&script.name);
|
||||
|
||||
if let Some(desc) = &script.description {
|
||||
cmd = cmd.about(desc)
|
||||
}
|
||||
|
||||
match &script.script {
|
||||
crate::model::CuddleScript::Shell(shell_script) => {
|
||||
if let Some(args) = &shell_script.args {
|
||||
for (arg_name, arg) in args {
|
||||
cmd = match arg {
|
||||
crate::model::CuddleShellScriptArg::Env(arg_env) => cmd.arg(
|
||||
Arg::new(arg_name.clone())
|
||||
.env(arg_name.to_uppercase().replace(".", "_"))
|
||||
.required(true),
|
||||
),
|
||||
crate::model::CuddleShellScriptArg::Flag(arg_flag) => {
|
||||
let mut arg_val = Arg::new(arg_name.clone())
|
||||
.env(arg_name.to_uppercase().replace(".", "_"))
|
||||
.long(arg_name);
|
||||
|
||||
if let Some(true) = arg_flag.required {
|
||||
arg_val = arg_val.required(true);
|
||||
}
|
||||
|
||||
if let Some(def) = &arg_flag.default_value {
|
||||
arg_val = arg_val.default_value(def);
|
||||
}
|
||||
|
||||
if let Some(desc) = &arg_flag.description {
|
||||
arg_val = arg_val.help(&*desc.clone().leak())
|
||||
}
|
||||
|
||||
cmd.arg(arg_val)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::model::CuddleScript::Dagger(_) => todo!(),
|
||||
crate::model::CuddleScript::Lua(l) => {}
|
||||
crate::model::CuddleScript::Rust(_) => todo!(),
|
||||
}
|
||||
|
||||
cmds.push(cmd)
|
||||
}
|
||||
|
||||
cmds
|
||||
}
|
||||
|
||||
pub fn execute_x(exe_submatch: &ArgMatches, cli: CuddleCli) -> anyhow::Result<()> {
|
||||
match exe_submatch.subcommand() {
|
||||
Some((name, action_matches)) => {
|
||||
log::trace!(action=name; "running action; name={}", name);
|
||||
match cli.scripts.iter().find(|ele| ele.name == name) {
|
||||
Some(script) => {
|
||||
script
|
||||
.clone()
|
||||
.execute(action_matches, cli.variables.clone())?;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("could not find a match")),
|
||||
}
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("could not find a match")),
|
||||
}
|
||||
}
|
28
cuddle/src/config.rs
Normal file
28
cuddle/src/config.rs
Normal file
@ -0,0 +1,28 @@
|
||||
use envconfig::Envconfig;
|
||||
|
||||
pub enum CuddleFetchPolicy {
|
||||
Always,
|
||||
Once,
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Envconfig, Clone, Debug)]
|
||||
pub struct CuddleConfig {
|
||||
#[envconfig(from = "CUDDLE_FETCH_POLICY", default = "once")]
|
||||
fetch_policy: String,
|
||||
}
|
||||
|
||||
impl CuddleConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
CuddleConfig::init_from_env().map_err(|e| anyhow::Error::from(e))
|
||||
}
|
||||
|
||||
pub fn get_fetch_policy(&self) -> anyhow::Result<CuddleFetchPolicy> {
|
||||
match self.fetch_policy.clone().to_lowercase().as_str() {
|
||||
"always" => Ok(CuddleFetchPolicy::Always),
|
||||
"once" => Ok(CuddleFetchPolicy::Once),
|
||||
"never" => Ok(CuddleFetchPolicy::Never),
|
||||
_ => Err(anyhow::anyhow!("could not parse fetch policy")),
|
||||
}
|
||||
}
|
||||
}
|
211
cuddle/src/context.rs
Normal file
211
cuddle/src/context.rs
Normal file
@ -0,0 +1,211 @@
|
||||
use std::{
|
||||
env::{self, current_dir},
|
||||
ffi::OsStr,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use git2::{
|
||||
build::{CheckoutBuilder, RepoBuilder},
|
||||
FetchOptions, RemoteCallbacks,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
config::{CuddleConfig, CuddleFetchPolicy},
|
||||
model::{CuddleBase, CuddlePlan},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum CuddleTreeType {
|
||||
Root,
|
||||
Leaf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CuddleContext {
|
||||
pub plan: CuddlePlan,
|
||||
pub path: PathBuf,
|
||||
pub node_type: CuddleTreeType,
|
||||
}
|
||||
|
||||
pub fn extract_cuddle(
|
||||
config: CuddleConfig,
|
||||
) -> anyhow::Result<Option<Arc<Mutex<Vec<CuddleContext>>>>> {
|
||||
let mut curr_dir = current_dir()?;
|
||||
curr_dir.push(".cuddle/");
|
||||
let fetch_policy = config.get_fetch_policy()?;
|
||||
if let CuddleFetchPolicy::Always = fetch_policy {
|
||||
if curr_dir.exists() {
|
||||
if let Err(res) = std::fs::remove_dir_all(curr_dir) {
|
||||
panic!("{}", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load main cuddle file.
|
||||
let cuddle_yaml = find_root_cuddle()?;
|
||||
if cuddle_yaml.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let cuddle_yaml = cuddle_yaml.unwrap();
|
||||
log::trace!(cuddle_yaml=log::as_debug!(cuddle_yaml); "Find root cuddle");
|
||||
|
||||
let cuddle_plan = serde_yaml::from_str::<CuddlePlan>(cuddle_yaml.as_str())?;
|
||||
log::debug!(cuddle_plan=log::as_debug!(cuddle_yaml); "parse cuddle plan");
|
||||
|
||||
let context: Arc<Mutex<Vec<CuddleContext>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
context.lock().unwrap().push(CuddleContext {
|
||||
plan: cuddle_plan.clone(),
|
||||
path: current_dir()?,
|
||||
node_type: CuddleTreeType::Root,
|
||||
});
|
||||
|
||||
// pull parent plan and execute recursive descent
|
||||
match cuddle_plan.base {
|
||||
CuddleBase::Bool(true) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"plan cannot be enabled without specifying a plan"
|
||||
))
|
||||
}
|
||||
CuddleBase::Bool(false) => {
|
||||
log::debug!("plan is root: skipping");
|
||||
}
|
||||
CuddleBase::String(parent_plan) => {
|
||||
let destination_path = create_cuddle_local()?;
|
||||
let mut cuddle_dest = destination_path.clone();
|
||||
cuddle_dest.push("base");
|
||||
|
||||
if !cuddle_dest.exists() {
|
||||
pull_parent_cuddle_into_local(parent_plan, cuddle_dest.clone())?;
|
||||
}
|
||||
|
||||
recurse_parent(cuddle_dest, context.clone())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(context))
|
||||
}
|
||||
|
||||
fn create_cuddle_local() -> anyhow::Result<PathBuf> {
|
||||
let mut curr_dir = current_dir()?.clone();
|
||||
curr_dir.push(".cuddle/");
|
||||
|
||||
if curr_dir.exists() {
|
||||
log::debug!(".cuddle/ already exists: skipping");
|
||||
return Ok(curr_dir);
|
||||
}
|
||||
|
||||
std::fs::create_dir(curr_dir.clone())?;
|
||||
|
||||
Ok(curr_dir)
|
||||
}
|
||||
|
||||
fn create_cuddle(path: PathBuf) -> anyhow::Result<PathBuf> {
|
||||
let mut curr_dir = path.clone();
|
||||
curr_dir.push(".cuddle/");
|
||||
|
||||
if curr_dir.exists() {
|
||||
log::debug!(".cuddle/ already exists: skipping");
|
||||
return Ok(curr_dir);
|
||||
}
|
||||
|
||||
std::fs::create_dir(curr_dir.clone())?;
|
||||
|
||||
Ok(curr_dir)
|
||||
}
|
||||
|
||||
fn pull_parent_cuddle_into_local(
|
||||
parent_cuddle: String,
|
||||
destination: PathBuf,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut rc = RemoteCallbacks::new();
|
||||
rc.credentials(|_url, username_from_url, _allowed_types| {
|
||||
if *"true" == std::env::var("CUDDLE_SSH_AGENT").ok().unwrap_or("".into()) {
|
||||
git2::Cred::ssh_key_from_agent(username_from_url.unwrap())
|
||||
} else {
|
||||
git2::Cred::ssh_key(
|
||||
username_from_url.unwrap(),
|
||||
None,
|
||||
Path::new(&format!("{}/.ssh/id_ed25519", env::var("HOME").unwrap())),
|
||||
None,
|
||||
)
|
||||
}
|
||||
});
|
||||
|
||||
rc.certificate_check(|_cert, _something| Ok(git2::CertificateCheckStatus::CertificateOk));
|
||||
|
||||
let mut fo = FetchOptions::new();
|
||||
fo.remote_callbacks(rc);
|
||||
|
||||
let co = CheckoutBuilder::new();
|
||||
RepoBuilder::new()
|
||||
.fetch_options(fo)
|
||||
.with_checkout(co)
|
||||
.clone(&parent_cuddle, &destination)?;
|
||||
|
||||
log::debug!(parent_cuddle=log::as_display!(parent_cuddle); "pulled repository");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn recurse_parent(
|
||||
path: PathBuf,
|
||||
context: Arc<Mutex<Vec<CuddleContext>>>,
|
||||
) -> anyhow::Result<Option<()>> {
|
||||
let cuddle_contents = find_cuddle(path.clone())?;
|
||||
if cuddle_contents.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let cuddle_plan = serde_yaml::from_str::<CuddlePlan>(&cuddle_contents.unwrap())?;
|
||||
|
||||
let ctx = context.clone();
|
||||
if let Ok(mut ctxs) = ctx.lock() {
|
||||
ctxs.push(CuddleContext {
|
||||
plan: cuddle_plan.clone(),
|
||||
path: path.clone(),
|
||||
node_type: CuddleTreeType::Leaf,
|
||||
});
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Could not acquire lock, aborting"));
|
||||
}
|
||||
|
||||
match cuddle_plan.base {
|
||||
CuddleBase::Bool(true) => Err(anyhow::anyhow!(
|
||||
"plan cannot be enabled without specifying a plan"
|
||||
)),
|
||||
CuddleBase::Bool(false) => {
|
||||
log::debug!("plan is root: finishing up");
|
||||
Ok(Some(()))
|
||||
}
|
||||
CuddleBase::String(parent_plan) => {
|
||||
let destination_path = create_cuddle(path.clone())?;
|
||||
let mut cuddle_dest = destination_path.clone();
|
||||
cuddle_dest.push("base");
|
||||
|
||||
if !cuddle_dest.exists() {
|
||||
pull_parent_cuddle_into_local(parent_plan, cuddle_dest.clone())?;
|
||||
}
|
||||
recurse_parent(cuddle_dest, context.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_root_cuddle() -> anyhow::Result<Option<String>> {
|
||||
// TODO: Make recursive towards root
|
||||
let current_dir = env::current_dir()?;
|
||||
find_cuddle(current_dir)
|
||||
}
|
||||
|
||||
fn find_cuddle(path: PathBuf) -> anyhow::Result<Option<String>> {
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
let metadata = std::fs::metadata(&path)?;
|
||||
if metadata.is_file() && path.file_name().unwrap() == OsStr::new("cuddle.yaml") {
|
||||
return Ok(Some(std::fs::read_to_string(path)?));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
31
cuddle/src/main.rs
Normal file
31
cuddle/src/main.rs
Normal file
@ -0,0 +1,31 @@
|
||||
use config::CuddleConfig;
|
||||
use tracing_subscriber::prelude::*;
|
||||
use tracing_subscriber::{fmt, EnvFilter};
|
||||
|
||||
mod actions;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod context;
|
||||
mod model;
|
||||
mod util;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
init_logging()?;
|
||||
let _ = dotenv::dotenv();
|
||||
|
||||
let config = CuddleConfig::from_env()?;
|
||||
|
||||
let context = context::extract_cuddle(config.clone())?;
|
||||
_ = cli::CuddleCli::new(context, config)?.execute()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn init_logging() -> anyhow::Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(fmt::layer())
|
||||
.with(EnvFilter::from_default_env())
|
||||
.init();
|
||||
|
||||
Ok(())
|
||||
}
|
238
cuddle/src/model.rs
Normal file
238
cuddle/src/model.rs
Normal file
@ -0,0 +1,238 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum CuddleBase {
|
||||
Bool(bool),
|
||||
String(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleShellScriptArgEnv {
|
||||
pub key: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleShellScriptArgFlag {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub required: Option<bool>,
|
||||
pub default_value: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CuddleShellScriptArg {
|
||||
#[serde(alias = "env")]
|
||||
Env(CuddleShellScriptArgEnv),
|
||||
#[serde(alias = "flag")]
|
||||
Flag(CuddleShellScriptArgFlag),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleShellScript {
|
||||
pub description: Option<String>,
|
||||
pub args: Option<HashMap<String, CuddleShellScriptArg>>,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleDaggerScript {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleLuaScript {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub enum CuddleRustUpstream {
|
||||
#[serde(alias = "gitea")]
|
||||
Gitea { url: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddleRustScript {
|
||||
pub description: Option<String>,
|
||||
pub upstream: CuddleRustUpstream,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum CuddleScript {
|
||||
#[serde(alias = "shell")]
|
||||
Shell(CuddleShellScript),
|
||||
#[serde(alias = "dagger")]
|
||||
Dagger(CuddleDaggerScript),
|
||||
#[serde(alias = "lua")]
|
||||
Lua(CuddleLuaScript),
|
||||
#[serde(alias = "rust")]
|
||||
Rust(CuddleRustScript),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
pub struct CuddlePlan {
|
||||
pub base: CuddleBase,
|
||||
pub vars: Option<CuddlePlanVariables>,
|
||||
pub scripts: Option<HashMap<String, CuddleScript>>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct CuddlePlanVariables(HashMap<String, CuddlePlanVar>);
|
||||
|
||||
impl From<CuddlePlanVariables> for Vec<CuddleVariable> {
|
||||
fn from(value: CuddlePlanVariables) -> Self {
|
||||
let variables: CuddleVariables = value.0.into();
|
||||
|
||||
let mut vars = variables.0;
|
||||
vars.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap());
|
||||
|
||||
vars
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum CuddlePlanVar {
|
||||
Str(String),
|
||||
Nested(HashMap<String, CuddlePlanVar>),
|
||||
Array(Vec<CuddlePlanVar>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct CuddleVariables(Vec<CuddleVariable>);
|
||||
|
||||
impl From<HashMap<String, CuddlePlanVar>> for CuddleVariables {
|
||||
fn from(value: HashMap<String, CuddlePlanVar>) -> Self {
|
||||
let mut variables = Vec::new();
|
||||
for (k, v) in value {
|
||||
match v {
|
||||
CuddlePlanVar::Str(value) => variables.push(CuddleVariable::new(k, value)),
|
||||
CuddlePlanVar::Nested(nested) => {
|
||||
let nested_variables: CuddleVariables = nested.into();
|
||||
|
||||
let mut combined_variables: Vec<_> = nested_variables
|
||||
.0
|
||||
.into_iter()
|
||||
.map(|v| CuddleVariable::new(format!("{}_{}", k, v.name), v.value))
|
||||
.collect();
|
||||
|
||||
variables.append(&mut combined_variables);
|
||||
}
|
||||
CuddlePlanVar::Array(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
CuddleVariables(variables)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize, serde::Serialize)]
|
||||
#[allow(dead_code)]
|
||||
pub struct CuddleVariable {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl CuddleVariable {
|
||||
pub fn new(name: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
value: value.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HashMap<String, CuddlePlanVar>> for CuddlePlanVar {
|
||||
fn from(value: HashMap<String, CuddlePlanVar>) -> Self {
|
||||
CuddlePlanVar::Nested(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<HashMap<&str, CuddlePlanVar>> for CuddlePlanVar {
|
||||
fn from(value: HashMap<&str, CuddlePlanVar>) -> Self {
|
||||
CuddlePlanVar::Nested(value.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for CuddlePlanVar {
|
||||
fn from(value: String) -> Self {
|
||||
CuddlePlanVar::Str(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for CuddlePlanVar {
|
||||
fn from(value: &str) -> Self {
|
||||
CuddlePlanVar::Str(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{CuddlePlanVariables, CuddleVariable};
|
||||
|
||||
#[test]
|
||||
pub fn parse_cuddle_variables() {
|
||||
let cuddle = r#"
|
||||
someKey: someValue
|
||||
someNestedKey:
|
||||
someNestedNestedKey:
|
||||
someKey: key
|
||||
someKey: key
|
||||
"#;
|
||||
|
||||
let cuddle_var: CuddlePlanVariables = serde_yaml::from_str(cuddle).unwrap();
|
||||
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert("someKey", "someValue".into());
|
||||
|
||||
let mut nested_key = HashMap::new();
|
||||
nested_key.insert("someKey", "key".into());
|
||||
|
||||
let mut nested_nested_key = HashMap::new();
|
||||
nested_nested_key.insert("someKey", "key".into());
|
||||
|
||||
nested_key.insert("someNestedNestedKey", nested_nested_key.into());
|
||||
|
||||
expected.insert("someNestedKey", nested_key.into());
|
||||
|
||||
assert_eq!(
|
||||
CuddlePlanVariables(
|
||||
expected
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k.to_string(), v))
|
||||
.collect()
|
||||
),
|
||||
cuddle_var
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn to_cuddle_variables() {
|
||||
let cuddle = r#"
|
||||
someKey: someValue
|
||||
someNestedKey:
|
||||
someNestedNestedKey:
|
||||
someKey: key
|
||||
someKey: key
|
||||
"#;
|
||||
|
||||
let cuddle_var: CuddlePlanVariables = serde_yaml::from_str(cuddle).unwrap();
|
||||
|
||||
let variables: Vec<CuddleVariable> = cuddle_var.into();
|
||||
|
||||
let mut expected: Vec<CuddleVariable> = vec![
|
||||
CuddleVariable::new("someKey", "someValue"),
|
||||
CuddleVariable::new("someNestedKey_someKey", "key"),
|
||||
CuddleVariable::new("someNestedKey_someNestedNestedKey_someKey", "key"),
|
||||
];
|
||||
|
||||
expected.sort_by(|a, b| a.name.partial_cmp(&b.name).unwrap());
|
||||
|
||||
assert_eq!(expected, variables);
|
||||
}
|
||||
}
|
33
cuddle/src/util/git.rs
Normal file
33
cuddle/src/util/git.rs
Normal file
@ -0,0 +1,33 @@
|
||||
use std::env::current_dir;
|
||||
|
||||
use git2::Repository;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct GitCommit {
|
||||
pub commit_sha: String,
|
||||
}
|
||||
|
||||
impl GitCommit {
|
||||
pub fn new() -> anyhow::Result<GitCommit> {
|
||||
let repo = Repository::open(current_dir().expect("having current_dir available")).map_err(
|
||||
|e| {
|
||||
log::debug!("{}", e);
|
||||
anyhow::anyhow!("could not open repository")
|
||||
},
|
||||
)?;
|
||||
let head_ref = repo
|
||||
.head()
|
||||
.map_err(|e| {
|
||||
log::warn!("{}", e);
|
||||
anyhow::anyhow!("could not get HEAD")
|
||||
})?
|
||||
.target()
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"could not extract head -> target to commit_sha"
|
||||
))?;
|
||||
|
||||
Ok(Self {
|
||||
commit_sha: head_ref.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
1
cuddle/src/util/mod.rs
Normal file
1
cuddle/src/util/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod git;
|
8
examples/base/Cargo.toml
Normal file
8
examples/base/Cargo.toml
Normal file
@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "base"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
8
examples/base/cuddle.yaml
Normal file
8
examples/base/cuddle.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
# yaml-language-server: $schema=../../schemas/base.json
|
||||
|
||||
base: "git@git.front.kjuulh.io:kjuulh/cuddle-rust-plan.git"
|
||||
|
||||
scripts:
|
||||
build:
|
||||
type: shell
|
||||
description: "build rust plan"
|
3
examples/base/scripts/build.sh
Executable file
3
examples/base/scripts/build.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Ran build"
|
3
examples/base/src/main.rs
Normal file
3
examples/base/src/main.rs
Normal file
@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
14
examples/templates/cuddle.yaml
Normal file
14
examples/templates/cuddle.yaml
Normal file
@ -0,0 +1,14 @@
|
||||
# yaml-language-server: $schema=../../schemas/base.json
|
||||
|
||||
base: false
|
||||
|
||||
vars:
|
||||
service: "some-service"
|
||||
|
||||
scripts:
|
||||
test_render_template:
|
||||
type: shell
|
||||
args:
|
||||
extravar:
|
||||
type: env
|
||||
key: "HOME"
|
6
examples/templates/scripts/test_render_template.sh
Executable file
6
examples/templates/scripts/test_render_template.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
CUDDLE_FETCH_POLICY=never cuddle render_template \
|
||||
--template-file "$TMP/input.txt.tmpl" \
|
||||
--destination "$TMP/input.txt" \
|
||||
--extra-var "extravar=someextravar"
|
3
examples/templates/templates/input.txt.tmpl
Normal file
3
examples/templates/templates/input.txt.tmpl
Normal file
@ -0,0 +1,3 @@
|
||||
some {{ service }} name
|
||||
|
||||
- {{ extravar }}
|
138
schemas/base.json
Normal file
138
schemas/base.json
Normal file
@ -0,0 +1,138 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"base": {
|
||||
"title": "Base url from which to base current cuddle plan on",
|
||||
"description": "Base url from which to construct current cuddle plan, is recursive",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"title": "The url of the parameter"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"title": "Whether it is enabled or not"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vars": {
|
||||
"type": "object",
|
||||
"title": "your collection of variables to be available to cuddle",
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"scripts": {
|
||||
"type": "object",
|
||||
"title": "Scripts the cuddle cli can execute",
|
||||
"description": "Scripts the cuddle cli can execute 'cuddle x my-awesome-script'",
|
||||
"additionalProperties": false,
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"shell",
|
||||
"dagger"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"vars": {
|
||||
"type": "object",
|
||||
"title": "your collection of variables to be available to cuddle",
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"args": {
|
||||
"title": "arguments to send to the specified script",
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
"^.*$": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"flag"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": [
|
||||
"type",
|
||||
"key"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"env"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"key": {
|
||||
"title": "the environment key to pull arg from",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"base"
|
||||
],
|
||||
"type": "object",
|
||||
"title": "Cuddle base schema"
|
||||
}
|
16
scripts/build_cuddle_image.sh
Executable file
16
scripts/build_cuddle_image.sh
Executable file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
tag="$REGISTRY/$SERVICE:${COMMIT_SHA:0:10}"
|
||||
latest_tag="$REGISTRY/$SERVICE:latest"
|
||||
|
||||
echo "logging in"
|
||||
docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD
|
||||
|
||||
echo "building image"
|
||||
DOCKER_BUILDKIT=1 docker build -t "$tag" -t "$latest_tag" -f "$TMP/build_cuddle_image.Dockerfile" .
|
||||
|
||||
echo "pushing image"
|
||||
docker push "$tag"
|
||||
docker push "$latest_tag"
|
5
scripts/install.sh
Executable file
5
scripts/install.sh
Executable file
@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
cargo install --path cuddle/ --force
|
||||
|
||||
cuddle --version
|
38
templates/build_cuddle_image.Dockerfile
Normal file
38
templates/build_cuddle_image.Dockerfile
Normal file
@ -0,0 +1,38 @@
|
||||
FROM rustlang/rust:nightly as base
|
||||
|
||||
RUN rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y \
|
||||
musl-tools \
|
||||
musl-dev
|
||||
|
||||
RUN update-ca-certificates
|
||||
|
||||
RUN apt-get update && apt-get upgrade -y
|
||||
RUN apt-get install -y -q build-essential curl git
|
||||
|
||||
WORKDIR /app/cuddle/
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo install --target x86_64-unknown-linux-musl --path cuddle
|
||||
|
||||
FROM docker:dind
|
||||
|
||||
RUN apk add bash git kubectl
|
||||
|
||||
ENV SLICE_VERSION=v1.2.7
|
||||
RUN wget -O kubectl-slice_linux_x86_64.tar.gz \
|
||||
"https://github.com/patrickdappollonio/kubectl-slice/releases/download/$SLICE_VERSION/kubectl-slice_linux_x86_64.tar.gz" && \
|
||||
tar -xf kubectl-slice_linux_x86_64.tar.gz && \
|
||||
chmod +x ./kubectl-slice && \
|
||||
mv ./kubectl-slice /usr/local/bin/kubectl-slice && \
|
||||
rm kubectl-slice_linux_x86_64.tar.gz
|
||||
|
||||
RUN eval `ssh-agent`
|
||||
|
||||
COPY --from=1password/op:2 /usr/local/bin/op /usr/local/bin/op
|
||||
|
||||
COPY --from=base /usr/local/cargo/bin/ /usr/local/cargo/bin/
|
||||
ENV PATH="${PATH}:/usr/local/cargo/bin"
|
3
templates/build_cuddle_image.Dockerfile.dockerignore
Normal file
3
templates/build_cuddle_image.Dockerfile.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
.cuddle/
|
||||
.git/
|
||||
target/
|
@ -1,15 +0,0 @@
|
||||
version: "3"
|
||||
services:
|
||||
crdb:
|
||||
restart: 'always'
|
||||
image: 'cockroachdb/cockroach:v23.1.14'
|
||||
command: 'start-single-node --advertise-addr 0.0.0.0 --insecure'
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health?ready=1"]
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
ports:
|
||||
- 8080:8080
|
||||
- '26257:26257'
|
Loading…
Reference in New Issue
Block a user