Merge pull request 'feat: harmony now defaults to using local k3d cluster. Also created OCICompliant: Application trait to make building images cleaner' (#76) from feat/oci into master
All checks were successful
Run Check Script / check (push) Successful in -9s
Compile and package harmony_composer / package_harmony_composer (push) Successful in 4m4s

Reviewed-on: https://git.nationtech.io/NationTech/harmony/pulls/76
This commit is contained in:
johnride 2025-07-03 19:37:46 +00:00
commit d9935e20cb
33 changed files with 1569 additions and 67 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
target
private_repos
log/
*.tgz

1
Cargo.lock generated
View File

@ -1765,6 +1765,7 @@ dependencies = [
"strum 0.27.1",
"temp-dir",
"temp-file",
"tempfile",
"tokio",
"tokio-util",
"url",

View File

@ -1,20 +1,36 @@
use std::{path::PathBuf, sync::Arc};
use harmony::{
inventory::Inventory,
maestro::Maestro,
modules::application::{RustWebappScore, features::ContinuousDelivery},
modules::application::{
RustWebFramework, RustWebapp, RustWebappScore, features::ContinuousDelivery,
},
topology::{K8sAnywhereTopology, Url},
};
#[tokio::main]
async fn main() {
env_logger::init();
let application = RustWebapp {
name: "harmony-example-rust-webapp".to_string(),
project_root: PathBuf::from("./examples/rust/webapp"),
framework: Some(RustWebFramework::Leptos),
};
// TODO RustWebappScore should simply take a RustWebApp as config
let app = RustWebappScore {
name: "Example Rust Webapp".to_string(),
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
features: vec![Box::new(ContinuousDelivery {})],
features: vec![Box::new(ContinuousDelivery {
application: Arc::new(application.clone()),
})],
application,
};
let topology = K8sAnywhereTopology::from_env();
let mut maestro = Maestro::new(Inventory::autoload(), topology);
let mut maestro = Maestro::initialize(Inventory::autoload(), topology)
.await
.unwrap();
maestro.register_all(vec![Box::new(app)]);
harmony_cli::init(maestro, None).await.unwrap();
}

14
examples/rust/webapp/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb

View File

@ -0,0 +1,93 @@
[package]
name = "harmony-example-rust-webapp"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[workspace]
[dependencies]
actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_error_panic_hook = "0.1"
http = { version = "1.0.0", optional = true }
leptos = { version = "0.7.0" }
leptos_meta = { version = "0.7.0" }
leptos_actix = { version = "0.7.0", optional = true }
leptos_router = { version = "0.7.0" }
wasm-bindgen = "=0.2.100"
[features]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
# Defines a size-optimized profile for the WASM bundle in release mode
[profile.wasm-release]
inherits = "release"
opt-level = 'z'
lto = true
codegen-units = 1
panic = "abort"
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "harmony-example-rust-webapp"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss"
# Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
#
# Optional. Env: LEPTOS_ASSETS_DIR.
assets-dir = "assets"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "0.0.0.0:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
# [Windows] for non-WSL use "npx.cmd playwright test"
# This binary name can be checked in Powershell with Get-Command npx
end2end-cmd = "npx playwright test"
end2end-dir = "end2end"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
# The profile to use for the lib target when compiling for release
#
# Optional. Defaults to "release".
lib-profile-release = "wasm-release"

View File

@ -0,0 +1,16 @@
FROM rust:bookworm as builder
RUN apt-get update && apt-get install -y --no-install-recommends clang wget && wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz && tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz && cp cargo-binstall /usr/local/cargo/bin && rm cargo-binstall-x86_64-unknown-linux-musl.tgz cargo-binstall && apt-get clean && rm -rf /var/lib/apt/lists/*
RUN cargo binstall cargo-leptos -y
RUN rustup target add wasm32-unknown-unknown
WORKDIR /app
COPY . .
RUN cargo leptos build --release -vv
FROM debian:bookworm-slim
RUN groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser
ENV LEPTOS_SITE_ADDR=0.0.0.0:3000
EXPOSE 3000/tcp
WORKDIR /home/appuser
COPY --from=builder /app/target/site/pkg /home/appuser/pkg
COPY --from=builder /app/target/release/harmony-example-rust-webapp /home/appuser/harmony-example-rust-webapp
USER appuser
CMD /home/appuser/harmony-example-rust-webapp

View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
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 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.
For more information, please refer to <https://unlicense.org>

View File

@ -0,0 +1,72 @@
<picture>
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
</picture>
# Leptos Starter Template
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
## Creating your template repo
If you don't have `cargo-leptos` installed you can install it with
`cargo install cargo-leptos --locked`
Then run
`cargo leptos new --git leptos-rs/start-actix`
to generate a new project template (you will be prompted to enter a project name).
`cd {projectname}`
to go to your newly created project.
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
## Running your project
`cargo leptos watch`
By default, you can access your local project at `http://localhost:3000`
## Installing Additional Tools
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
## Executing a Server on a Remote Machine Without the Toolchain
After running a `cargo leptos build --release` the minimum files needed are:
1. The server binary located in `target/server/release`
2. The `site` directory and all files within located in `target/site`
Copy these files to your remote server. The directory structure should be:
```text
leptos_start
site/
```
Set the following environment variables (updating for your project as needed):
```sh
export LEPTOS_OUTPUT_NAME="leptos_start"
export LEPTOS_SITE_ROOT="site"
export LEPTOS_SITE_PKG_DIR="pkg"
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
export LEPTOS_RELOAD_PORT="3001"
```
Finally, run the server binary.
## Notes about CSR and Trunk:
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
`trunk serve --open --features csr`
This may be useful for integrating external tools which require a static site, e.g. `tauri`.
## Licensing
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1,112 @@
{
"name": "end2end",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "end2end",
"version": "1.0.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
},
"node_modules/@playwright/test": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/@types/node": {
"version": "20.12.12",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.44.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.44.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,15 @@
{
"name": "end2end",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.44.1",
"@types/node": "^20.12.12",
"typescript": "^5.4.5"
}
}

View File

@ -0,0 +1,104 @@
import { devices, defineConfig } from "@playwright/test";
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: "./tests",
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
},
/* Configure projects for major browsers */
projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],
},
},
{
name: "firefox",
use: {
...devices["Desktop Firefox"],
},
},
{
name: "webkit",
use: {
...devices["Desktop Safari"],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// port: 3000,
// },
});

View File

@ -0,0 +1,9 @@
import { test, expect } from "@playwright/test";
test("homepage has title and links to intro page", async ({ page }) => {
await page.goto("http://localhost:3000/");
await expect(page).toHaveTitle("Welcome to Leptos");
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
});

View File

@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

View File

@ -0,0 +1,66 @@
use leptos::prelude::*;
use leptos_meta::{provide_meta_context, Stylesheet, Title};
use leptos_router::{
components::{Route, Router, Routes},
StaticSegment, WildcardSegment,
};
#[component]
pub fn App() -> impl IntoView {
// Provides context that manages stylesheets, titles, meta tags, etc.
provide_meta_context();
view! {
// injects a stylesheet into the document <head>
// id=leptos means cargo-leptos will hot-reload this stylesheet
<Stylesheet id="leptos" href="/pkg/harmony-example-rust-webapp.css"/>
// sets the document title
<Title text="Welcome to Leptos"/>
// content for this welcome page
<Router>
<main>
<Routes fallback=move || "Not found.">
<Route path=StaticSegment("") view=HomePage/>
<Route path=WildcardSegment("any") view=NotFound/>
</Routes>
</main>
</Router>
}
}
/// Renders the home page of your application.
#[component]
fn HomePage() -> impl IntoView {
// Creates a reactive value to update the button
let count = RwSignal::new(0);
let on_click = move |_| *count.write() += 1;
view! {
<h1>"Welcome to Leptos!"</h1>
<button on:click=on_click>"Click Me: " {count}</button>
}
}
/// 404 - Not Found
#[component]
fn NotFound() -> impl IntoView {
// set an HTTP status code 404
// this is feature gated because it can only be done during
// initial server-side rendering
// if you navigate to the 404 page subsequently, the status
// code will not be set because there is not a new HTTP request
// to the server
#[cfg(feature = "ssr")]
{
// this can be done inline because it's synchronous
// if it were async, we'd use a server function
let resp = expect_context::<leptos_actix::ResponseOptions>();
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
}
view! {
<h1>"Not Found"</h1>
}
}

View File

@ -0,0 +1,9 @@
pub mod app;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use app::*;
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}

View File

@ -0,0 +1,88 @@
#[cfg(feature = "ssr")]
#[actix_web::main]
async fn main() -> std::io::Result<()> {
use actix_files::Files;
use actix_web::*;
use leptos::prelude::*;
use leptos::config::get_configuration;
use leptos_meta::MetaTags;
use leptos_actix::{generate_route_list, LeptosRoutes};
use harmony_example_rust_webapp::app::*;
let conf = get_configuration(None).unwrap();
let addr = conf.leptos_options.site_addr;
HttpServer::new(move || {
// Generate the list of routes in your Leptos App
let routes = generate_route_list(App);
let leptos_options = &conf.leptos_options;
let site_root = leptos_options.site_root.clone().to_string();
println!("listening on http://{}", &addr);
App::new()
// serve JS/WASM/CSS from `pkg`
.service(Files::new("/pkg", format!("{site_root}/pkg")))
// serve other assets from the `assets` directory
.service(Files::new("/assets", &site_root))
// serve the favicon from /favicon.ico
.service(favicon)
.leptos_routes(routes, {
let leptos_options = leptos_options.clone();
move || {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=leptos_options.clone() />
<HydrationScripts options=leptos_options.clone()/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
})
.app_data(web::Data::new(leptos_options.to_owned()))
//.wrap(middleware::Compress::default())
})
.bind(&addr)?
.run()
.await
}
#[cfg(feature = "ssr")]
#[actix_web::get("favicon.ico")]
async fn favicon(
leptos_options: actix_web::web::Data<leptos::config::LeptosOptions>,
) -> actix_web::Result<actix_files::NamedFile> {
let leptos_options = leptos_options.into_inner();
let site_root = &leptos_options.site_root;
Ok(actix_files::NamedFile::open(format!(
"{site_root}/favicon.ico"
))?)
}
#[cfg(not(any(feature = "ssr", feature = "csr")))]
pub fn main() {
// no client-side main function
// unless we want this to work with e.g., Trunk for pure client-side testing
// see lib.rs for hydration function instead
// see optional feature `csr` instead
}
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
pub fn main() {
// a client-side main function is required for using `trunk serve`
// prefer using `cargo leptos serve` instead
// to run: `trunk serve --open --features csr`
use harmony_example_rust_webapp::app::*;
console_error_panic_hook::set_once();
leptos::mount_to_body(App);
}

View File

@ -0,0 +1,4 @@
body {
font-family: sans-serif;
text-align: center;
}

View File

@ -57,3 +57,4 @@ similar.workspace = true
futures-util = "0.3.31"
tokio-util = "0.7.15"
strum = { version = "0.27.1", features = ["derive"] }
tempfile = "3.20.0"

View File

@ -2,7 +2,7 @@ use lazy_static::lazy_static;
use std::path::PathBuf;
lazy_static! {
pub static ref HARMONY_CONFIG_DIR: PathBuf = directories::BaseDirs::new()
pub static ref HARMONY_DATA_DIR: PathBuf = directories::BaseDirs::new()
.unwrap()
.data_dir()
.join("harmony");

View File

@ -19,7 +19,10 @@ pub struct Maestro<T: Topology> {
}
impl<T: Topology> Maestro<T> {
pub fn new(inventory: Inventory, topology: T) -> Self {
/// Creates a bare maestro without initialization.
///
/// This should rarely be used. Most of the time Maestro::initialize should be used instead.
pub fn new_without_initialization(inventory: Inventory, topology: T) -> Self {
Self {
inventory,
topology,
@ -29,7 +32,7 @@ impl<T: Topology> Maestro<T> {
}
pub async fn initialize(inventory: Inventory, topology: T) -> Result<Self, InterpretError> {
let instance = Self::new(inventory, topology);
let instance = Self::new_without_initialization(inventory, topology);
instance.prepare_topology().await?;
Ok(instance)
}

View File

@ -16,7 +16,7 @@ use crate::{
};
use super::{
HelmCommand, K8sclient, Topology,
DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, Topology,
k8s::K8sClient,
tenant::{TenantConfig, TenantManager, k8s::K8sTenantManager},
};
@ -123,39 +123,47 @@ impl K8sAnywhereTopology {
async fn try_get_or_install_k8s_client(&self) -> Result<Option<K8sState>, InterpretError> {
let k8s_anywhere_config = &self.config;
if let Some(kubeconfig) = &k8s_anywhere_config.kubeconfig {
debug!("Loading kubeconfig {kubeconfig}");
match self.try_load_kubeconfig(&kubeconfig).await {
Some(client) => {
return Ok(Some(K8sState {
client: Arc::new(client),
_source: K8sSource::Kubeconfig,
message: format!("Loaded k8s client from kubeconfig {kubeconfig}"),
}));
}
None => {
return Err(InterpretError::new(format!(
"Failed to load kubeconfig from {kubeconfig}"
)));
// TODO this deserves some refactoring, it is becoming a bit hard to figure out
// be careful when making modifications here
if k8s_anywhere_config.use_local_k3d {
info!("Using local k3d cluster because of use_local_k3d set to true");
} else {
if let Some(kubeconfig) = &k8s_anywhere_config.kubeconfig {
debug!("Loading kubeconfig {kubeconfig}");
match self.try_load_kubeconfig(&kubeconfig).await {
Some(client) => {
return Ok(Some(K8sState {
client: Arc::new(client),
_source: K8sSource::Kubeconfig,
message: format!("Loaded k8s client from kubeconfig {kubeconfig}"),
}));
}
None => {
return Err(InterpretError::new(format!(
"Failed to load kubeconfig from {kubeconfig}"
)));
}
}
}
}
if k8s_anywhere_config.use_system_kubeconfig {
debug!("Loading system kubeconfig");
match self.try_load_system_kubeconfig().await {
Some(_client) => todo!(),
None => todo!(),
if k8s_anywhere_config.use_system_kubeconfig {
debug!("Loading system kubeconfig");
match self.try_load_system_kubeconfig().await {
Some(_client) => todo!(),
None => todo!(),
}
}
}
info!("No kubernetes configuration found");
info!("No kubernetes configuration found");
}
if !k8s_anywhere_config.autoinstall {
debug!("Autoinstall confirmation prompt");
let confirmation = Confirm::new( "Harmony autoinstallation is not activated, do you wish to launch autoinstallation? : ")
.with_default(false)
.prompt()
.expect("Unexpected prompt error");
debug!("Autoinstall confirmation {confirmation}");
if !confirmation {
warn!(
@ -229,8 +237,16 @@ pub struct K8sAnywhereConfig {
///
/// When enabled, autoinstall will setup a K3D cluster on the localhost. https://k3d.io/stable/
///
/// Default: true
/// Default: false
pub autoinstall: bool,
/// Whether to use local k3d cluster.
///
/// Takes precedence over other options, useful to avoid messing up a remote cluster by mistake
///
/// default: true
pub use_local_k3d: bool,
harmony_profile: String,
}
impl K8sAnywhereConfig {
@ -241,6 +257,13 @@ impl K8sAnywhereConfig {
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
autoinstall: std::env::var("HARMONY_AUTOINSTALL")
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
// TODO harmony_profile should be managed at a more core level than this
harmony_profile: std::env::var("HARMONY_PROFILE").map_or_else(
|_| "dev".to_string(),
|v| v.parse().ok().unwrap_or("dev".to_string()),
),
use_local_k3d: std::env::var("HARMONY_USE_LOCAL_K3D")
.map_or_else(|_| true, |v| v.parse().ok().unwrap_or(true)),
}
}
}
@ -275,6 +298,20 @@ impl Topology for K8sAnywhereTopology {
}
}
impl MultiTargetTopology for K8sAnywhereTopology {
fn current_target(&self) -> DeploymentTarget {
if self.config.use_local_k3d {
return DeploymentTarget::LocalDev;
}
match self.config.harmony_profile.to_lowercase().as_str() {
"staging" => DeploymentTarget::Staging,
"production" => DeploymentTarget::Production,
_ => todo!("HARMONY_PROFILE must be set when use_local_k3d is not set"),
}
}
}
impl HelmCommand for K8sAnywhereTopology {}
#[async_trait]

View File

@ -62,6 +62,17 @@ pub trait Topology: Send + Sync {
async fn ensure_ready(&self) -> Result<Outcome, InterpretError>;
}
#[derive(Debug)]
pub enum DeploymentTarget {
LocalDev,
Staging,
Production,
}
pub trait MultiTargetTopology: Topology {
fn current_target(&self) -> DeploymentTarget;
}
pub type IpAddress = IpAddr;
#[derive(Debug, Clone)]

View File

@ -2,6 +2,8 @@ use async_trait::async_trait;
use serde::Serialize;
use crate::topology::Topology;
use super::Application;
/// An ApplicationFeature provided by harmony, such as Backups, Monitoring, MultisiteAvailability,
/// ContinuousIntegration, ContinuousDelivery
#[async_trait]

View File

@ -1,13 +1,20 @@
use std::{io::Write, process::Command, sync::Arc};
use async_trait::async_trait;
use log::info;
use log::{error, info};
use serde_yaml::Value;
use tempfile::NamedTempFile;
use crate::{
config::HARMONY_DATA_DIR,
data::Version,
inventory::Inventory,
modules::{application::ApplicationFeature, helm::chart::HelmChartScore},
modules::{
application::{Application, ApplicationFeature, HelmPackage, OCICompliant},
helm::chart::HelmChartScore,
},
score::Score,
topology::{HelmCommand, Topology, Url},
topology::{DeploymentTarget, HelmCommand, MultiTargetTopology, Topology, Url},
};
/// ContinuousDelivery in Harmony provides this functionality :
@ -38,33 +45,164 @@ use crate::{
/// - ArgoCD to install/upgrade/rollback/inspect k8s resources
/// - Kubernetes for runtime orchestration
#[derive(Debug, Default, Clone)]
pub struct ContinuousDelivery {}
pub struct ContinuousDelivery<A: OCICompliant + HelmPackage> {
pub application: Arc<A>,
}
impl<A: OCICompliant + HelmPackage> ContinuousDelivery<A> {
async fn deploy_to_local_k3d(
&self,
app_name: String,
chart_url: String,
image_name: String,
) -> Result<(), String> {
error!(
"FIXME This works only with local k3d installations, which is fine only for current demo purposes. We assume usage of K8sAnywhereTopology"
);
error!("TODO hardcoded k3d bin path is wrong");
let k3d_bin_path = (*HARMONY_DATA_DIR).join("k3d").join("k3d");
// --- 1. Import the container image into the k3d cluster ---
info!(
"Importing image '{}' into k3d cluster 'harmony'",
image_name
);
let import_output = Command::new(&k3d_bin_path)
.args(["image", "import", &image_name, "--cluster", "harmony"])
.output()
.map_err(|e| format!("Failed to execute k3d image import: {}", e))?;
if !import_output.status.success() {
return Err(format!(
"Failed to import image to k3d: {}",
String::from_utf8_lossy(&import_output.stderr)
));
}
// --- 2. Get the kubeconfig for the k3d cluster and write it to a temp file ---
info!("Retrieving kubeconfig for k3d cluster 'harmony'");
let kubeconfig_output = Command::new(&k3d_bin_path)
.args(["kubeconfig", "get", "harmony"])
.output()
.map_err(|e| format!("Failed to execute k3d kubeconfig get: {}", e))?;
if !kubeconfig_output.status.success() {
return Err(format!(
"Failed to get kubeconfig from k3d: {}",
String::from_utf8_lossy(&kubeconfig_output.stderr)
));
}
let mut temp_kubeconfig = NamedTempFile::new()
.map_err(|e| format!("Failed to create temp file for kubeconfig: {}", e))?;
temp_kubeconfig
.write_all(&kubeconfig_output.stdout)
.map_err(|e| format!("Failed to write to temp kubeconfig file: {}", e))?;
let kubeconfig_path = temp_kubeconfig.path().to_str().unwrap();
// --- 3. Install or upgrade the Helm chart in the cluster ---
info!(
"Deploying Helm chart '{}' to namespace '{}'",
chart_url, app_name
);
let release_name = app_name.to_lowercase(); // Helm release names are often lowercase
let helm_output = Command::new("helm")
.args([
"upgrade",
"--install",
&release_name,
&chart_url,
"--namespace",
&app_name,
"--create-namespace",
"--wait", // Wait for the deployment to be ready
"--kubeconfig",
kubeconfig_path,
])
.spawn()
.map_err(|e| format!("Failed to execute helm upgrade: {}", e))?
.wait_with_output()
.map_err(|e| format!("Failed to execute helm upgrade: {}", e))?;
if !helm_output.status.success() {
return Err(format!(
"Failed to deploy Helm chart: {}",
String::from_utf8_lossy(&helm_output.stderr)
));
}
info!("Successfully deployed '{}' to local k3d cluster.", app_name);
Ok(())
}
}
#[async_trait]
impl<T: Topology + HelmCommand + 'static> ApplicationFeature<T> for ContinuousDelivery {
impl<
A: OCICompliant + HelmPackage + Clone + 'static,
T: Topology + HelmCommand + MultiTargetTopology + 'static,
> ApplicationFeature<T> for ContinuousDelivery<A>
{
async fn ensure_installed(&self, topology: &T) -> Result<(), String> {
let image = self.application.image_name();
// TODO
error!(
"TODO reverse helm chart packaging and docker image build. I put helm package first for faster iterations"
);
let helm_chart = self.application.build_push_helm_package(&image).await?;
info!("Pushed new helm chart {helm_chart}");
// let image = self.application.build_push_oci_image().await?;
// info!("Pushed new docker image {image}");
error!("uncomment above");
info!("Installing ContinuousDelivery feature");
let cd_server = HelmChartScore {
namespace: todo!(
"ArgoCD Helm chart with proper understanding of Tenant, see how Will did it for Monitoring for now"
),
release_name: todo!("argocd helm chart whatever"),
chart_name: todo!(),
chart_version: todo!(),
values_overrides: todo!(),
values_yaml: todo!(),
create_namespace: todo!(),
install_only: todo!(),
repository: todo!(),
// TODO this is a temporary hack for demo purposes, the deployment target should be driven
// by the topology only and we should not have to know how to perform tasks like this for
// which the topology should be responsible.
//
// That said, this will require some careful architectural decisions, since the concept of
// deployment targets / profiles is probably a layer of complexity that we won't be
// completely able to avoid
//
// I'll try something for now that must be thought through after : att a deployment_profile
// function to the topology trait that returns a profile, then anybody who needs it can
// access it. This forces every Topology to understand the concept of targets though... So
// instead I'll create a new Capability which is MultiTargetTopology and we'll see how it
// goes. It still does not feel right though.
match topology.current_target() {
DeploymentTarget::LocalDev => {
self.deploy_to_local_k3d(self.application.name(), helm_chart, image)
.await?;
}
target => {
info!("Deploying to target {target:?}");
let cd_server = HelmChartScore {
namespace: todo!(
"ArgoCD Helm chart with proper understanding of Tenant, see how Will did it for Monitoring for now"
),
release_name: todo!("argocd helm chart whatever"),
chart_name: todo!(),
chart_version: todo!(),
values_overrides: todo!(),
values_yaml: todo!(),
create_namespace: todo!(),
install_only: todo!(),
repository: todo!(),
};
let interpret = cd_server.create_interpret();
interpret.execute(&Inventory::empty(), topology);
}
};
let interpret = cd_server.create_interpret();
interpret.execute(&Inventory::empty(), topology);
todo!("1. Create ArgoCD score that installs argo using helm chart, see if Taha's already done it
2. Package app (docker image, helm chart)
3. Push to registry if staging or prod
4. Poke Argo
5. Ensure app is up")
- [X] Package app (docker image, helm chart)
- [X] Push to registry
- [ ] Push only if staging or prod
- [ ] Deploy to local k3d when target is local
- [ ] Poke Argo
- [ ] Ensure app is up")
}
fn name(&self) -> String {
"ContinuousDelivery".to_string()

View File

@ -2,7 +2,7 @@ use async_trait::async_trait;
use log::info;
use crate::{
modules::application::ApplicationFeature,
modules::application::{Application, ApplicationFeature},
topology::{K8sclient, Topology},
};

View File

@ -2,7 +2,7 @@ use async_trait::async_trait;
use log::info;
use crate::{
modules::application::ApplicationFeature,
modules::application::{Application, ApplicationFeature},
topology::{HelmCommand, Topology},
};

View File

@ -1,8 +1,12 @@
mod feature;
pub mod features;
pub mod oci;
mod rust;
use std::sync::Arc;
pub use feature::*;
use log::info;
pub use oci::*;
pub use rust::*;
use async_trait::async_trait;
@ -21,7 +25,7 @@ pub trait Application: std::fmt::Debug + Send + Sync {
#[derive(Debug)]
pub struct ApplicationInterpret<T: Topology + std::fmt::Debug> {
features: Vec<Box<dyn ApplicationFeature<T>>>,
application: Box<dyn Application>,
application: Arc<Box<dyn Application>>,
}
#[async_trait]

View File

@ -0,0 +1,21 @@
use async_trait::async_trait;
use super::Application;
#[async_trait]
pub trait OCICompliant: Application {
async fn build_push_oci_image(&self) -> Result<String, String>; // TODO consider using oci-spec and friends crates here
fn image_name(&self) -> String;
fn local_image_name(&self) -> String;
}
#[async_trait]
pub trait HelmPackage: Application {
/// Generates, packages, and pushes a Helm chart for the web application to an OCI registry.
///
/// # Arguments
/// * `image_url` - The full URL of the OCI container image to be used in the Deployment.
async fn build_push_helm_package(&self, image_url: &str) -> Result<String, String>;
}

View File

@ -1,26 +1,36 @@
use std::fs;
use std::path::PathBuf;
use std::process;
use std::sync::Arc;
use async_trait::async_trait;
use dockerfile_builder::Dockerfile;
use dockerfile_builder::instruction::{CMD, COPY, ENV, EXPOSE, FROM, RUN, USER, WORKDIR};
use dockerfile_builder::instruction_builder::CopyBuilder;
use log::{debug, error, info};
use serde::Serialize;
use crate::config::{REGISTRY_PROJECT, REGISTRY_URL};
use crate::{
score::Score,
topology::{Topology, Url},
};
use super::{Application, ApplicationFeature, ApplicationInterpret};
use super::{Application, ApplicationFeature, ApplicationInterpret, HelmPackage, OCICompliant};
#[derive(Debug, Serialize, Clone)]
pub struct RustWebappScore<T: Topology + Clone + Serialize> {
pub name: String,
pub domain: Url,
pub features: Vec<Box<dyn ApplicationFeature<T>>>,
pub application: RustWebapp,
}
impl<T: Topology + std::fmt::Debug + Clone + Serialize + 'static> Score<T> for RustWebappScore<T> {
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
Box::new(ApplicationInterpret {
features: self.features.clone(),
application: Box::new(RustWebapp {
name: self.name.clone(),
}),
application: Arc::new(Box::new(self.application.clone())),
})
}
@ -29,9 +39,17 @@ impl<T: Topology + std::fmt::Debug + Clone + Serialize + 'static> Score<T> for R
}
}
#[derive(Debug)]
struct RustWebapp {
name: String,
#[derive(Debug, Clone, Serialize)]
pub enum RustWebFramework {
Leptos,
}
#[derive(Debug, Clone, Serialize)]
pub struct RustWebapp {
pub name: String,
/// The path to the root of the Rust project to be containerized.
pub project_root: PathBuf,
pub framework: Option<RustWebFramework>,
}
impl Application for RustWebapp {
@ -39,3 +57,517 @@ impl Application for RustWebapp {
self.name.clone()
}
}
#[async_trait]
impl HelmPackage for RustWebapp {
async fn build_push_helm_package(&self, image_url: &str) -> Result<String, String> {
info!("Starting Helm chart build and push for '{}'", self.name);
// 1. Create the Helm chart files on disk.
let chart_dir = self
.create_helm_chart_files(image_url)
.map_err(|e| format!("Failed to create Helm chart files: {}", e))?;
info!("Successfully created Helm chart files in {:?}", chart_dir);
// 2. Package the chart into a .tgz archive.
let packaged_chart_path = self
.package_helm_chart(&chart_dir)
.map_err(|e| format!("Failed to package Helm chart: {}", e))?;
info!(
"Successfully packaged Helm chart: {}",
packaged_chart_path.to_string_lossy()
);
// 3. Push the packaged chart to the OCI registry.
let oci_chart_url = self
.push_helm_chart(&packaged_chart_path)
.map_err(|e| format!("Failed to push Helm chart: {}", e))?;
info!("Successfully pushed Helm chart to: {}", oci_chart_url);
Ok(oci_chart_url)
}
}
#[async_trait]
impl OCICompliant for RustWebapp {
/// Builds a Docker image for the Rust web application using a multi-stage build,
/// pushes it to the configured OCI registry, and returns the full image tag.
async fn build_push_oci_image(&self) -> Result<String, String> {
// This function orchestrates the build and push process.
// It's async to match the trait definition, though the underlying docker commands are blocking.
info!("Starting OCI image build and push for '{}'", self.name);
// 1. Build the local image by calling the synchronous helper function.
let local_image_name = self.local_image_name();
self.build_docker_image(&local_image_name)
.map_err(|e| format!("Failed to build Docker image: {}", e))?;
info!(
"Successfully built local Docker image: {}",
local_image_name
);
let remote_image_name = self.image_name();
// 2. Push the image to the registry.
self.push_docker_image(&local_image_name, &remote_image_name)
.map_err(|e| format!("Failed to push Docker image: {}", e))?;
info!("Successfully pushed Docker image to: {}", remote_image_name);
Ok(remote_image_name)
}
fn local_image_name(&self) -> String {
self.name.clone()
}
fn image_name(&self) -> String {
format!(
"{}/{}/{}",
*REGISTRY_URL,
*REGISTRY_PROJECT,
&self.local_image_name()
)
}
}
/// Implementation of helper methods for building and pushing the Docker image.
impl RustWebapp {
/// Generates a multi-stage Dockerfile for a Rust application.
fn build_dockerfile(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
let mut dockerfile = Dockerfile::new();
self.build_builder_image(&mut dockerfile);
// Save the Dockerfile to a uniquely named file in the project root to avoid conflicts.
let dockerfile_path = self.project_root.join("Dockerfile.harmony");
fs::write(&dockerfile_path, dockerfile.to_string())?;
Ok(dockerfile_path)
}
/// Builds the Docker image using the generated Dockerfile.
pub fn build_docker_image(
&self,
image_name: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Generating Dockerfile for '{}'", self.name);
let dockerfile_path = self.build_dockerfile()?;
info!(
"Building Docker image with file {} from root {}",
dockerfile_path.to_string_lossy(),
self.project_root.to_string_lossy()
);
let output = process::Command::new("docker")
.args([
"build",
"--file",
dockerfile_path.to_str().unwrap(),
"-t",
&image_name,
self.project_root.to_str().unwrap(),
])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Failed to build Docker image")?;
Ok(image_name.to_string())
}
/// Tags and pushes a Docker image to the configured remote registry.
fn push_docker_image(
&self,
image_name: &str,
full_tag: &str,
) -> Result<String, Box<dyn std::error::Error>> {
info!("Pushing docker image {full_tag}");
// Tag the image for the remote registry.
let output = process::Command::new("docker")
.args(["tag", image_name, &full_tag])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Tagging docker image failed")?;
debug!(
"docker tag output: stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
// Push the image.
let output = process::Command::new("docker")
.args(["push", &full_tag])
.spawn()?
.wait_with_output()?;
self.check_output(&output, "Pushing docker image failed")?;
debug!(
"docker push output: stdout: {}, stderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Ok(full_tag.to_string())
}
/// Checks the output of a process command for success.
fn check_output(
&self,
output: &process::Output,
msg: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if !output.status.success() {
let error_message = format!("{}: {}", msg, String::from_utf8_lossy(&output.stderr));
return Err(error_message.into());
}
Ok(())
}
fn build_builder_image(&self, dockerfile: &mut Dockerfile) {
match self.framework {
Some(RustWebFramework::Leptos) => {
// --- Stage 1: Builder for Leptos ---
dockerfile.push(FROM::from("rust:bookworm as builder"));
// Install dependencies, cargo-binstall, and clean up in one layer
dockerfile.push(RUN::from(
"apt-get update && \
apt-get install -y --no-install-recommends clang wget && \
wget https://github.com/cargo-bins/cargo-binstall/releases/latest/download/cargo-binstall-x86_64-unknown-linux-musl.tgz && \
tar -xvf cargo-binstall-x86_64-unknown-linux-musl.tgz && \
cp cargo-binstall /usr/local/cargo/bin && \
rm cargo-binstall-x86_64-unknown-linux-musl.tgz cargo-binstall && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*"
));
// Install cargo-leptos
dockerfile.push(RUN::from("cargo binstall cargo-leptos -y"));
// Add the WASM target
dockerfile.push(RUN::from("rustup target add wasm32-unknown-unknown"));
// Set up workdir, copy source, and build
dockerfile.push(WORKDIR::from("/app"));
dockerfile.push(COPY::from(". ."));
dockerfile.push(RUN::from("cargo leptos build --release -vv"));
// --- Stage 2: Final Image ---
dockerfile.push(FROM::from("debian:bookworm-slim"));
// Create a non-root user for security.
dockerfile.push(RUN::from(
"groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser",
));
dockerfile.push(ENV::from("LEPTOS_SITE_ADDR=0.0.0.0:3000"));
dockerfile.push(EXPOSE::from("3000/tcp"));
dockerfile.push(WORKDIR::from("/home/appuser"));
// Copy static files
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src("/app/target/site/pkg")
.dest("/home/appuser/pkg")
.build()
.unwrap(),
);
// Copy the compiled binary from the builder stage.
error!(
"FIXME Should not be using score name here, instead should use name from Cargo.toml"
);
let binary_path_in_builder = format!("/app/target/release/{}", self.name);
let binary_path_in_final = format!("/home/appuser/{}", self.name);
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src(binary_path_in_builder)
.dest(&binary_path_in_final)
.build()
.unwrap(),
);
// Run as the non-root user.
dockerfile.push(USER::from("appuser"));
// Set the command to run the application.
dockerfile.push(CMD::from(binary_path_in_final));
}
None => {
// --- Stage 1: Builder for a generic Rust app ---
dockerfile.push(FROM::from("rust:latest as builder"));
// Install the wasm32 target as required.
dockerfile.push(RUN::from("rustup target add wasm32-unknown-unknown"));
dockerfile.push(WORKDIR::from("/app"));
// Copy the source code and build the application.
dockerfile.push(COPY::from(". ."));
dockerfile.push(RUN::from("cargo build --release --locked"));
// --- Stage 2: Final Image ---
dockerfile.push(FROM::from("debian:bookworm-slim"));
// Create a non-root user for security.
dockerfile.push(RUN::from(
"groupadd -r appgroup && useradd -r -s /bin/false -g appgroup appuser",
));
// Copy only the compiled binary from the builder stage.
error!(
"FIXME Should not be using score name here, instead should use name from Cargo.toml"
);
let binary_path_in_builder = format!("/app/target/release/{}", self.name);
let binary_path_in_final = format!("/usr/local/bin/{}", self.name);
dockerfile.push(
CopyBuilder::builder()
.from("builder")
.src(binary_path_in_builder)
.dest(&binary_path_in_final)
.build()
.unwrap(),
);
// Run as the non-root user.
dockerfile.push(USER::from("appuser"));
// Set the command to run the application.
dockerfile.push(CMD::from(binary_path_in_final));
}
}
}
/// Creates all necessary files for a basic Helm chart.
fn create_helm_chart_files(
&self,
image_url: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let chart_name = format!("{}-chart", self.name);
let chart_dir = self.project_root.join("helm").join(&chart_name);
let templates_dir = chart_dir.join("templates");
fs::create_dir_all(&templates_dir)?;
let (image_repo, image_tag) = image_url.rsplit_once(':').unwrap_or((image_url, "latest"));
// Create Chart.yaml
let chart_yaml = format!(
r#"
apiVersion: v2
name: {}
description: A Helm chart for the {} web application.
type: application
version: 0.1.0
appVersion: "{}"
"#,
chart_name, self.name, image_tag
);
fs::write(chart_dir.join("Chart.yaml"), chart_yaml)?;
// Create values.yaml
let values_yaml = format!(
r#"
# Default values for {}.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: {}
pullPolicy: IfNotPresent
# Overridden by the chart's appVersion
tag: "{}"
service:
type: ClusterIP
port: 3000
ingress:
enabled: true
# Annotations for cert-manager to handle SSL.
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
# Add other annotations like nginx ingress class if needed
# kubernetes.io/ingress.class: nginx
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls:
- secretName: {}-tls
hosts:
- chart-example.local
"#,
chart_name, image_repo, image_tag, self.name
);
fs::write(chart_dir.join("values.yaml"), values_yaml)?;
// Create templates/_helpers.tpl
let helpers_tpl = r#"
{{/*
Expand the name of the chart.
*/}}
{{- define "chart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
*/}}
{{- define "chart.fullname" -}}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
"#;
fs::write(templates_dir.join("_helpers.tpl"), helpers_tpl)?;
// Create templates/service.yaml
let service_yaml = r#"
apiVersion: v1
kind: Service
metadata:
name: {{ include "chart.fullname" . }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: 3000
protocol: TCP
name: http
selector:
app: {{ include "chart.name" . }}
"#;
fs::write(templates_dir.join("service.yaml"), service_yaml)?;
// Create templates/deployment.yaml
let deployment_yaml = r#"
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "chart.fullname" . }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
app: {{ include "chart.name" . }}
template:
metadata:
labels:
app: {{ include "chart.name" . }}
spec:
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 3000
protocol: TCP
"#;
fs::write(templates_dir.join("deployment.yaml"), deployment_yaml)?;
// Create templates/ingress.yaml
let ingress_yaml = r#"
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "chart.fullname" . }}
annotations:
{{- toYaml .Values.ingress.annotations | nindent 4 }}
spec:
{{- if .Values.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
{{- end }}
secretName: {{ .secretName }}
{{- end }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "chart.fullname" $ }}
port:
number: 3000
{{- end }}
{{- end }}
{{- end }}
"#;
fs::write(templates_dir.join("ingress.yaml"), ingress_yaml)?;
Ok(chart_dir)
}
/// Packages a Helm chart directory into a .tgz file.
fn package_helm_chart(
&self,
chart_dir: &PathBuf,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let chart_dirname = chart_dir.file_name().expect("Should find a chart dirname");
info!(
"Launching `helm package {}` cli with CWD {}",
chart_dirname.to_string_lossy(),
&self.project_root.join("helm").to_string_lossy()
);
let output = process::Command::new("helm")
.args(["package", chart_dirname.to_str().unwrap()])
.current_dir(&self.project_root.join("helm")) // Run package from the parent dir
.output()?;
self.check_output(&output, "Failed to package Helm chart")?;
// Helm prints the path of the created chart to stdout.
let tgz_name = String::from_utf8(output.stdout)?
.trim()
.split_whitespace()
.last()
.unwrap_or_default()
.to_string();
if tgz_name.is_empty() {
return Err("Could not determine packaged chart filename.".into());
}
// The output from helm is relative, so we join it with the execution directory.
Ok(self.project_root.join("helm").join(tgz_name))
}
/// Pushes a packaged Helm chart to an OCI registry.
fn push_helm_chart(
&self,
packaged_chart_path: &PathBuf,
) -> Result<String, Box<dyn std::error::Error>> {
// The chart name is the file stem of the .tgz file
let chart_file_name = packaged_chart_path.file_stem().unwrap().to_str().unwrap();
let oci_push_url = format!("oci://{}/{}", *REGISTRY_URL, *REGISTRY_PROJECT);
let oci_pull_url = format!("{oci_push_url}/{}-chart", self.name);
info!(
"Pushing Helm chart {} to {}",
packaged_chart_path.to_string_lossy(),
oci_push_url
);
let output = process::Command::new("helm")
.args(["push", packaged_chart_path.to_str().unwrap(), &oci_push_url])
.output()?;
self.check_output(&output, "Pushing Helm chart failed")?;
// The final URL includes the version tag, which is part of the file name
let version = chart_file_name.rsplit_once('-').unwrap().1;
debug!("pull url {oci_pull_url}");
debug!("push url {oci_push_url}");
Ok(format!("{}:{}", oci_pull_url, version))
}
}

View File

@ -5,7 +5,7 @@ use log::info;
use serde::Serialize;
use crate::{
config::HARMONY_CONFIG_DIR,
config::HARMONY_DATA_DIR,
data::{Id, Version},
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
inventory::Inventory,
@ -22,7 +22,7 @@ pub struct K3DInstallationScore {
impl Default for K3DInstallationScore {
fn default() -> Self {
Self {
installation_path: HARMONY_CONFIG_DIR.join("k3d"),
installation_path: HARMONY_DATA_DIR.join("k3d"),
cluster_name: "harmony".to_string(),
}
}

View File

@ -153,7 +153,7 @@ mod test {
fn init_test_maestro() -> Maestro<HAClusterTopology> {
let inventory = Inventory::autoload();
let topology = HAClusterTopology::autoload();
let mut maestro = Maestro::new(inventory, topology);
let mut maestro = Maestro::new_without_initialization(inventory, topology);
maestro.register_all(vec![
Box::new(SuccessScore {}),

View File

@ -41,7 +41,7 @@ pub mod tui {
/// async fn main() {
/// let inventory = Inventory::autoload();
/// let topology = HAClusterTopology::autoload();
/// let mut maestro = Maestro::new(inventory, topology);
/// let mut maestro = Maestro::new_without_initialization(inventory, topology);
///
/// maestro.register_all(vec![
/// Box::new(SuccessScore {}),