Merge pull request 'Machine update: fix upload sources from machine flake, instead of current directory' (#1896) from hsjobeki/clan-core:hsjobeki-main into main
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import dataclasses
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -116,10 +115,10 @@ class WebExecutor(GObject.Object):
|
||||
# Introspect the function and create the expected dataclass from dict dynamically
|
||||
# Depending on the introspected argument_type
|
||||
arg_class = self.jschema_api.get_method_argtype(method_name, k)
|
||||
if dataclasses.is_dataclass(arg_class):
|
||||
|
||||
# TODO: rename from_dict into something like construct_checked_value
|
||||
# from_dict really takes Anything and returns an instance of the type/class
|
||||
reconciled_arguments[k] = from_dict(arg_class, v)
|
||||
else:
|
||||
reconciled_arguments[k] = v
|
||||
|
||||
GLib.idle_add(fn_instance._async_run, reconciled_arguments)
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ def dataclass_to_dict(obj: Any, *, use_alias: bool = True) -> Any:
|
||||
|
||||
|
||||
T = TypeVar("T", bound=dataclass) # type: ignore
|
||||
G = TypeVar("G") # type: ignore
|
||||
|
||||
|
||||
def is_union_type(type_hint: type | UnionType) -> bool:
|
||||
@@ -120,7 +121,7 @@ def unwrap_none_type(type_hint: type | UnionType) -> type:
|
||||
JsonValue = str | float | dict[str, Any] | list[Any] | None
|
||||
|
||||
|
||||
def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any:
|
||||
def construct_value(t: type, field_value: JsonValue, loc: list[str] = []) -> Any:
|
||||
"""
|
||||
Construct a field value from a type hint and a field value.
|
||||
"""
|
||||
@@ -129,7 +130,7 @@ def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any
|
||||
# If the field is another dataclass
|
||||
# Field_value must be a dictionary
|
||||
if is_dataclass(t) and isinstance(field_value, dict):
|
||||
return from_dict(t, field_value)
|
||||
return construct_dataclass(t, field_value)
|
||||
|
||||
# If the field expects a path
|
||||
# Field_value must be a string
|
||||
@@ -161,7 +162,7 @@ def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any
|
||||
# Unwrap the union type
|
||||
t = unwrap_none_type(t)
|
||||
# Construct the field value
|
||||
return construct_field(t, field_value)
|
||||
return construct_value(t, field_value)
|
||||
|
||||
# Nested types
|
||||
# list
|
||||
@@ -170,10 +171,10 @@ def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any
|
||||
if not isinstance(field_value, list):
|
||||
raise ClanError(f"Expected list, got {field_value}", location=f"{loc}")
|
||||
|
||||
return [construct_field(get_args(t)[0], item) for item in field_value]
|
||||
return [construct_value(get_args(t)[0], item) for item in field_value]
|
||||
elif get_origin(t) is dict and isinstance(field_value, dict):
|
||||
return {
|
||||
key: construct_field(get_args(t)[1], value)
|
||||
key: construct_value(get_args(t)[1], value)
|
||||
for key, value in field_value.items()
|
||||
}
|
||||
elif get_origin(t) is Literal:
|
||||
@@ -186,7 +187,7 @@ def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any
|
||||
|
||||
elif get_origin(t) is Annotated:
|
||||
(base_type,) = get_args(t)
|
||||
return construct_field(base_type, field_value)
|
||||
return construct_value(base_type, field_value)
|
||||
|
||||
# elif get_origin(t) is Union:
|
||||
|
||||
@@ -195,7 +196,7 @@ def construct_field(t: type, field_value: JsonValue, loc: list[str] = []) -> Any
|
||||
raise ClanError(f"Unhandled field type {t} with value {field_value}")
|
||||
|
||||
|
||||
def from_dict(t: type[T], data: dict[str, Any], path: list[str] = []) -> T:
|
||||
def construct_dataclass(t: type[T], data: dict[str, Any], path: list[str] = []) -> T:
|
||||
"""
|
||||
type t MUST be a dataclass
|
||||
Dynamically instantiate a data class from a dictionary, handling nested data classes.
|
||||
@@ -231,7 +232,7 @@ def from_dict(t: type[T], data: dict[str, Any], path: list[str] = []) -> T:
|
||||
):
|
||||
field_values[field.name] = None
|
||||
else:
|
||||
field_values[field.name] = construct_field(field_type, field_value)
|
||||
field_values[field.name] = construct_value(field_type, field_value)
|
||||
|
||||
# Check that all required field are present.
|
||||
for field_name in required:
|
||||
@@ -242,3 +243,12 @@ def from_dict(t: type[T], data: dict[str, Any], path: list[str] = []) -> T:
|
||||
)
|
||||
|
||||
return t(**field_values) # type: ignore
|
||||
|
||||
|
||||
def from_dict(t: type[G], data: dict[str, Any] | Any, path: list[str] = []) -> G:
|
||||
if is_dataclass(t):
|
||||
if not isinstance(data, dict):
|
||||
raise ClanError(f"{data} is not a dict. Expected {t}")
|
||||
return construct_dataclass(t, data, path) # type: ignore
|
||||
else:
|
||||
return construct_value(t, data, path)
|
||||
|
||||
@@ -5,11 +5,15 @@ import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
from clan_cli.api import API
|
||||
from clan_cli.clan_uri import FlakeId
|
||||
|
||||
from ..cmd import run
|
||||
from ..completions import add_dynamic_completer, complete_machines
|
||||
from ..errors import ClanError
|
||||
from ..facts.generate import generate_facts
|
||||
from ..facts.upload import upload_secrets
|
||||
from ..inventory import Machine as InventoryMachine
|
||||
from ..machines.machines import Machine
|
||||
from ..nix import nix_command, nix_metadata
|
||||
from ..ssh import HostKeyCheck
|
||||
@@ -81,6 +85,25 @@ def upload_sources(
|
||||
)
|
||||
|
||||
|
||||
@API.register
|
||||
def update_machines(base_path: str, machines: list[InventoryMachine]) -> None:
|
||||
group_machines: list[Machine] = []
|
||||
|
||||
# Convert InventoryMachine to Machine
|
||||
for machine in machines:
|
||||
m = Machine(
|
||||
name=machine.name,
|
||||
flake=FlakeId(base_path),
|
||||
)
|
||||
if not machine.deploy.targetHost:
|
||||
raise ClanError(f"'TargetHost' is not set for machine '{machine.name}'")
|
||||
# Copy targetHost to machine
|
||||
m.target_host_address = machine.deploy.targetHost
|
||||
group_machines.append(m)
|
||||
|
||||
deploy_machine(MachineGroup(group_machines))
|
||||
|
||||
|
||||
def deploy_machine(machines: MachineGroup) -> None:
|
||||
"""
|
||||
Deploy to all hosts in parallel
|
||||
@@ -97,8 +120,10 @@ def deploy_machine(machines: MachineGroup) -> None:
|
||||
generate_vars([machine], None, False)
|
||||
upload_secrets(machine)
|
||||
|
||||
path = upload_sources(".", target)
|
||||
|
||||
path = upload_sources(
|
||||
str(machine.flake.path) if machine.flake.is_local() else machine.flake.url,
|
||||
target,
|
||||
)
|
||||
if host.host_key_check != HostKeyCheck.STRICT:
|
||||
ssh_arg += " -o StrictHostKeyChecking=no"
|
||||
if host.host_key_check == HostKeyCheck.NONE:
|
||||
|
||||
@@ -157,6 +157,21 @@ def test_nullable_non_exist() -> None:
|
||||
from_dict(Person, person_dict)
|
||||
|
||||
|
||||
def test_list() -> None:
|
||||
data = [
|
||||
{"name": "John"},
|
||||
{"name": "Sarah"},
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class Name:
|
||||
name: str
|
||||
|
||||
result = from_dict(list[Name], data)
|
||||
|
||||
assert result == [Name("John"), Name("Sarah")]
|
||||
|
||||
|
||||
def test_deserialize_extensive_inventory() -> None:
|
||||
# TODO: Make this an abstract test, so it doesn't break the test if the inventory changes
|
||||
data = {
|
||||
|
||||
@@ -16,8 +16,91 @@ interface MachineListItemProps {
|
||||
export const MachineListItem = (props: MachineListItemProps) => {
|
||||
const { name, info, nixOnly } = props;
|
||||
|
||||
const [deploying, setDeploying] = createSignal<boolean>(false);
|
||||
// Bootstrapping
|
||||
const [installing, setInstalling] = createSignal<boolean>(false);
|
||||
|
||||
// Later only updates
|
||||
const [updating, setUpdating] = createSignal<boolean>(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!info?.deploy.targetHost || installing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeURI();
|
||||
if (!active_clan) {
|
||||
toast.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
if (!info?.deploy.targetHost) {
|
||||
toast.error(
|
||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setInstalling(true);
|
||||
await toast.promise(
|
||||
callApi("install_machine", {
|
||||
opts: {
|
||||
machine: name,
|
||||
flake: {
|
||||
loc: active_clan,
|
||||
},
|
||||
no_reboot: true,
|
||||
target_host: info?.deploy.targetHost,
|
||||
debug: true,
|
||||
nix_options: [],
|
||||
},
|
||||
password: null,
|
||||
}),
|
||||
{
|
||||
loading: "Installing...",
|
||||
success: "Installed",
|
||||
error: "Failed to install",
|
||||
},
|
||||
);
|
||||
setInstalling(false);
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!info?.deploy.targetHost || installing()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeURI();
|
||||
if (!active_clan) {
|
||||
toast.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
if (!info?.deploy.targetHost) {
|
||||
toast.error(
|
||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setUpdating(true);
|
||||
await toast.promise(
|
||||
callApi("update_machines", {
|
||||
base_path: active_clan,
|
||||
machines: [
|
||||
{
|
||||
name: name,
|
||||
deploy: {
|
||||
targetHost: info?.deploy.targetHost,
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
loading: "Updating...",
|
||||
success: "Updated",
|
||||
error: "Failed to update",
|
||||
},
|
||||
);
|
||||
setUpdating(false);
|
||||
};
|
||||
return (
|
||||
<li>
|
||||
<div class="card card-side m-2 bg-base-200">
|
||||
@@ -73,51 +156,25 @@ export const MachineListItem = (props: MachineListItemProps) => {
|
||||
</li>
|
||||
<li
|
||||
classList={{
|
||||
disabled: !info?.deploy.targetHost || deploying(),
|
||||
}}
|
||||
onClick={async (e) => {
|
||||
if (!info?.deploy.targetHost || deploying()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active_clan = activeURI();
|
||||
if (!active_clan) {
|
||||
toast.error("No active clan selected");
|
||||
return;
|
||||
}
|
||||
if (!info?.deploy.targetHost) {
|
||||
toast.error(
|
||||
"Machine does not have a target host. Specify where the machine should be deployed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setDeploying(true);
|
||||
await toast.promise(
|
||||
callApi("install_machine", {
|
||||
opts: {
|
||||
machine: name,
|
||||
flake: {
|
||||
loc: active_clan,
|
||||
},
|
||||
no_reboot: true,
|
||||
target_host: info?.deploy.targetHost,
|
||||
debug: true,
|
||||
nix_options: [],
|
||||
},
|
||||
password: null,
|
||||
}),
|
||||
{
|
||||
loading: "Deploying...",
|
||||
success: "Deployed",
|
||||
error: "Failed to deploy",
|
||||
},
|
||||
);
|
||||
setDeploying(false);
|
||||
disabled: !info?.deploy.targetHost || installing(),
|
||||
}}
|
||||
onClick={handleInstall}
|
||||
>
|
||||
<a>
|
||||
<Show when={info?.deploy.targetHost} fallback={"Deploy"}>
|
||||
{(d) => `Deploy to ${d()}`}
|
||||
{(d) => `Install to ${d()}`}
|
||||
</Show>
|
||||
</a>
|
||||
</li>
|
||||
<li
|
||||
classList={{
|
||||
disabled: !info?.deploy.targetHost || updating(),
|
||||
}}
|
||||
onClick={handleUpdate}
|
||||
>
|
||||
<a>
|
||||
<Show when={info?.deploy.targetHost} fallback={"Deploy"}>
|
||||
{(d) => `Update (${d()})`}
|
||||
</Show>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user