diff --git a/e2e/internal/kuberesource/mutators.go b/e2e/internal/kuberesource/mutators.go new file mode 100644 index 0000000000..650f8ce568 --- /dev/null +++ b/e2e/internal/kuberesource/mutators.go @@ -0,0 +1,49 @@ +package kuberesource + +import ( + "errors" + + applyappsv1 "k8s.io/client-go/applyconfigurations/apps/v1" + applycorev1 "k8s.io/client-go/applyconfigurations/core/v1" +) + +// AddInitializer adds an initializer to a deployment. +func AddInitializer( + deployment *applyappsv1.DeploymentApplyConfiguration, + initializer *applycorev1.ContainerApplyConfiguration, +) (*applyappsv1.DeploymentApplyConfiguration, error) { + if initializer == nil { + return nil, errors.New("initializer is nil") + } + if deployment == nil { + return nil, errors.New("deployment is nil") + } + if deployment.Spec == nil { + return nil, errors.New("deployment.Spec is nil") + } + if deployment.Spec.Template == nil { + return nil, errors.New("deployment.Spec.Template is nil") + } + if deployment.Spec.Template.Spec == nil { + return nil, errors.New("deployment.Spec.Template.Spec is nil") + } + if len(deployment.Spec.Template.Spec.Containers) == 0 { + return nil, errors.New("deployment.Spec.Template.Spec.Containers is empty") + } + + // Add the initializer as an init container. + deployment.Spec.Template.Spec.WithInitContainers( + initializer, + ) + // Create the volume written by the initializer. + deployment.Spec.Template.Spec.WithVolumes(Volume(). + WithName("tls-certs"). + WithEmptyDir(EmptyDirVolumeSource().Inner()), + ) + // Add the volume mount written by the initializer to the worker container. + deployment.Spec.Template.Spec.Containers[0].WithVolumeMounts(VolumeMount(). + WithName("tls-certs"). + WithMountPath("/tls-config"), + ) + return deployment, nil +} diff --git a/e2e/internal/kuberesource/parts.go b/e2e/internal/kuberesource/parts.go new file mode 100644 index 0000000000..8df4062e6c --- /dev/null +++ b/e2e/internal/kuberesource/parts.go @@ -0,0 +1,149 @@ +package kuberesource + +import ( + "strconv" + + applyappsv1 "k8s.io/client-go/applyconfigurations/apps/v1" + applycorev1 "k8s.io/client-go/applyconfigurations/core/v1" +) + +// PortForwarderConfig wraps a PodApplyConfiguration for a port forwarder. +type PortForwarderConfig struct { + *applycorev1.PodApplyConfiguration +} + +// PortForwarder constructs a port forwarder pod. +func PortForwarder(name, namespace string) *PortForwarderConfig { + name = "port-forwarder-" + name + + p := Pod(name, namespace). + WithLabels(map[string]string{"app.kubernetes.io/name": name}). + WithSpec(PodSpec(). + WithContainers( + Container(). + WithName("port-forwarder"). + WithImage("ghcr.io/edgelesssys/nunki/port-forwarder:latest"). + WithCommand("/bin/bash", "-c", "echo Starting port-forward with socat; exec socat -d -d TCP-LISTEN:${LISTEN_PORT},fork TCP:${FORWARD_HOST}:${FORWARD_PORT}"). + WithResources(ResourceRequirements(). + WithMemoryLimitAndRequest(50), + ), + ), + ) + + return &PortForwarderConfig{p} +} + +// WithListenPort sets the port to listen on. +func (p *PortForwarderConfig) WithListenPort(port int32) *PortForwarderConfig { + p.Spec.Containers[0]. + WithPorts( + ContainerPort(). + WithContainerPort(port), + ). + WithEnv( + NewEnvVar("LISTEN_PORT", strconv.Itoa(int(port))), + ) + return p +} + +// WithForwardTarget sets the target host and port to forward to. +func (p *PortForwarderConfig) WithForwardTarget(host string, port int32) *PortForwarderConfig { + p.Spec.Containers[0]. + WithEnv( + NewEnvVar("FORWARD_HOST", host), + NewEnvVar("FORWARD_PORT", strconv.Itoa(int(port))), + ) + return p +} + +// CoordinatorConfig wraps applyappsv1.DeploymentApplyConfiguration for a coordinator. +type CoordinatorConfig struct { + *applyappsv1.DeploymentApplyConfiguration +} + +// Coordinator constructs a new CoordinatorConfig. +func Coordinator(namespace string) *CoordinatorConfig { + c := Deployment("coordinator", namespace). + WithSpec(DeploymentSpec(). + WithReplicas(1). + WithSelector(LabelSelector(). + WithMatchLabels(map[string]string{"app.kubernetes.io/name": "coordinator"}), + ). + WithTemplate(PodTemplateSpec(). + WithLabels(map[string]string{"app.kubernetes.io/name": "coordinator"}). + WithAnnotations(map[string]string{"nunki.edgeless.systems/pod-role": "coordinator"}). + WithSpec(PodSpec(). + WithRuntimeClassName("kata-cc-isolation"). + WithContainers( + Container(). + WithName("coordinator"). + WithImage("ghcr.io/edgelesssys/nunki/coordinator:latest"). + WithEnv( + NewEnvVar("NUNKI_LOG_LEVEL", "debug"), + ). + WithPorts( + ContainerPort(). + WithName("userapi"). + WithContainerPort(1313), + ContainerPort(). + WithName("meshapi"). + WithContainerPort(7777), + ). + WithResources(ResourceRequirements(). + WithMemoryLimitAndRequest(100), + ), + ), + ), + ), + ) + + return &CoordinatorConfig{c} +} + +// WithImage sets the image of the coordinator. +func (c *CoordinatorConfig) WithImage(image string) *CoordinatorConfig { + c.Spec.Template.Spec.Containers[0].WithImage(image) + return c +} + +// GetDeploymentConfig returns the DeploymentConfig of the coordinator. +func (c *CoordinatorConfig) GetDeploymentConfig() *DeploymentConfig { + return &DeploymentConfig{c.DeploymentApplyConfiguration} +} + +// ServiceForDeployment creates a service for a deployment by exposing the configured ports +// of the deployment's first container. +func ServiceForDeployment(d *applyappsv1.DeploymentApplyConfiguration) *applycorev1.ServiceApplyConfiguration { + selector := d.Spec.Selector.MatchLabels + ports := d.Spec.Template.Spec.Containers[0].Ports + + s := Service(*d.Name, *d.Namespace). + WithSpec(ServiceSpec(). + WithSelector(selector), + ) + + for _, p := range ports { + s.Spec.WithPorts( + ServicePort(). + WithName(*p.Name). + WithPort(*p.ContainerPort), + ) + } + + return s +} + +// Initializer creates a new InitializerConfig. +func Initializer() *applycorev1.ContainerApplyConfiguration { + return applycorev1.Container(). + WithName("initializer"). + WithImage("ghcr.io/edgelesssys/nunki/initializer:latest"). + WithResources(ResourceRequirements(). + WithMemoryLimitAndRequest(50), + ). + WithEnv(NewEnvVar("COORDINATOR_HOST", "coordinator")). + WithVolumeMounts(VolumeMount(). + WithName("tls-certs"). + WithMountPath("/tls-config"), + ) +} diff --git a/e2e/internal/kuberesource/parts_test.go b/e2e/internal/kuberesource/parts_test.go new file mode 100644 index 0000000000..9a14dd2ff8 --- /dev/null +++ b/e2e/internal/kuberesource/parts_test.go @@ -0,0 +1,29 @@ +package kuberesource + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNewPortForwarder(t *testing.T) { + require := require.New(t) + + config := PortForwarder("coordinator", "default"). + WithListenPort(1313). + WithForwardTarget("coordinator", 1313) + + b, err := EncodeResources(config) + require.NoError(err) + t.Log("\n" + string(b)) +} + +func TestCoordinator(t *testing.T) { + require := require.New(t) + + config := Coordinator("default") + + b, err := EncodeResources(config) + require.NoError(err) + t.Log("\n" + string(b)) +} diff --git a/e2e/internal/kuberesource/resourcegen/main.go b/e2e/internal/kuberesource/resourcegen/main.go new file mode 100644 index 0000000000..a98403425d --- /dev/null +++ b/e2e/internal/kuberesource/resourcegen/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "path" + + "github.com/edgelesssys/nunki/e2e/internal/kuberesource" +) + +func main() { + if len(os.Args) != 3 { + fmt.Println("Usage: kuberesource ") + os.Exit(1) + } + + set := os.Args[1] + dest := os.Args[2] + + var resources []any + var err error + switch set { + case "simple": + resources, err = kuberesource.Simple() + default: + fmt.Printf("Error: unknown set: %s\n", set) + os.Exit(1) + } + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + b, err := kuberesource.EncodeResources(resources...) + if err != nil { + fmt.Printf("Error: %v\n", err) + os.Exit(1) + } + + if err := os.Mkdir(path.Dir(dest), 0o755); err != nil { + fmt.Println(err) + os.Exit(1) + } + if err := os.WriteFile(dest, b, 0o644); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/e2e/internal/kuberesource/sets.go b/e2e/internal/kuberesource/sets.go new file mode 100644 index 0000000000..80b7bc54fa --- /dev/null +++ b/e2e/internal/kuberesource/sets.go @@ -0,0 +1,51 @@ +package kuberesource + +// Simple returns a simple set of resources for testing. +func Simple() ([]any, error) { + ns := "edg-default" + + namespace := Namespace(ns) + coordinator := Coordinator(ns).DeploymentApplyConfiguration + coordinatorService := ServiceForDeployment(coordinator) + coordinatorForwarder := PortForwarder("coordinator", ns). + WithListenPort(1313). + WithForwardTarget("coordinator", 1313). + PodApplyConfiguration + + workload := Deployment("workload", ns). + WithSpec(DeploymentSpec(). + WithReplicas(1). + WithSelector(LabelSelector(). + WithMatchLabels(map[string]string{"app.kubernetes.io/name": "workload"}), + ). + WithTemplate(PodTemplateSpec(). + WithLabels(map[string]string{"app.kubernetes.io/name": "workload"}). + WithSpec(PodSpec(). + WithRuntimeClassName("kata-cc-isolation"). + WithContainers( + Container(). + WithName("workload"). + WithImage("docker.io/library/busybox:1.36.1-musl@sha256:d4707523ce6e12afdbe9a3be5ad69027150a834870ca0933baf7516dd1fe0f56"). + WithCommand("/bin/sh", "-c", "echo Workload started ; while true; do sleep 60; done"). + WithResources(ResourceRequirements(). + WithMemoryLimitAndRequest(50), + ), + ), + ), + ), + ) + workload, err := AddInitializer(workload, Initializer()) + if err != nil { + return nil, err + } + + resources := []any{ + namespace, + coordinator, + coordinatorService, + coordinatorForwarder, + workload, + } + + return resources, nil +} diff --git a/e2e/internal/kuberesource/wrappers.go b/e2e/internal/kuberesource/wrappers.go new file mode 100644 index 0000000000..9477e11013 --- /dev/null +++ b/e2e/internal/kuberesource/wrappers.go @@ -0,0 +1,217 @@ +package kuberesource + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + + applyappsv1 "k8s.io/client-go/applyconfigurations/apps/v1" + applycorev1 "k8s.io/client-go/applyconfigurations/core/v1" + applymetav1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// DeploymentConfig wraps applyappsv1.DeploymentApplyConfiguration. +type DeploymentConfig struct { + *applyappsv1.DeploymentApplyConfiguration +} + +// Deployment creates a new DeploymentConfig. +func Deployment(name, namespace string) *DeploymentConfig { + return &DeploymentConfig{applyappsv1.Deployment(name, namespace)} +} + +// DeploymentSpecConfig wraps applyappsv1.DeploymentSpecApplyConfiguration. +type DeploymentSpecConfig struct { + *applyappsv1.DeploymentSpecApplyConfiguration +} + +// DeploymentSpec creates a new DeploymentSpecConfig. +func DeploymentSpec() *DeploymentSpecConfig { + return &DeploymentSpecConfig{applyappsv1.DeploymentSpec()} +} + +// PodConfig wraps applyappsv1.PodApplyConfiguration. +type PodConfig struct { + *applycorev1.PodApplyConfiguration +} + +// Pod creates a new PodConfig. +func Pod(name, namespace string) *PodConfig { + return &PodConfig{applycorev1.Pod(name, namespace)} +} + +// LabelSelectorConfig wraps applymetav1.LabelSelectorApplyConfiguration. +type LabelSelectorConfig struct { + *applymetav1.LabelSelectorApplyConfiguration +} + +// LabelSelector creates a new LabelSelectorConfig. +func LabelSelector() *LabelSelectorConfig { + return &LabelSelectorConfig{applymetav1.LabelSelector()} +} + +// PodTemplateSpecConfig wraps applycorev1.PodTemplateSpecApplyConfiguration. +type PodTemplateSpecConfig struct { + *applycorev1.PodTemplateSpecApplyConfiguration +} + +// PodTemplateSpec creates a new PodTemplateSpecConfig. +func PodTemplateSpec() *PodTemplateSpecConfig { + return &PodTemplateSpecConfig{applycorev1.PodTemplateSpec()} +} + +// PodSpecConfig wraps applycorev1.PodSpecApplyConfiguration. +type PodSpecConfig struct { + *applycorev1.PodSpecApplyConfiguration +} + +// PodSpec creates a new PodSpecConfig. +func PodSpec() *PodSpecConfig { + return &PodSpecConfig{applycorev1.PodSpec()} +} + +// ContainerConfig wraps applycorev1.ContainerApplyConfiguration. +type ContainerConfig struct { + *applycorev1.ContainerApplyConfiguration +} + +// Container creates a new ContainerConfig. +func Container() *ContainerConfig { + return &ContainerConfig{applycorev1.Container()} +} + +// EnvVarConfig wraps applycorev1.EnvVarApplyConfiguration. +type EnvVarConfig struct { + *applycorev1.EnvVarApplyConfiguration +} + +// EnvVar creates a new EnvVarConfig. +func EnvVar() *EnvVarConfig { + return &EnvVarConfig{applycorev1.EnvVar()} +} + +// NewEnvVar creates a new EnvVarApplyConfiguration from name and value. +func NewEnvVar(name, value string) *applycorev1.EnvVarApplyConfiguration { + return applycorev1.EnvVar().WithName(name).WithValue(value) +} + +// VolumeMountConfig wraps applycorev1.VolumeMountApplyConfiguration. +type VolumeMountConfig struct { + *applycorev1.VolumeMountApplyConfiguration +} + +// VolumeMount creates a new VolumeMountConfig. +func VolumeMount() *VolumeMountConfig { + return &VolumeMountConfig{applycorev1.VolumeMount()} +} + +// ResourceRequirementsConfig wraps applycorev1.ResourceRequirementsApplyConfiguration. +type ResourceRequirementsConfig struct { + *applycorev1.ResourceRequirementsApplyConfiguration +} + +// ResourceRequirements creates a new ResourceRequirementsConfig. +func ResourceRequirements() *ResourceRequirementsConfig { + return &ResourceRequirementsConfig{applycorev1.ResourceRequirements()} +} + +// WithMemoryLimitAndRequest sets the memory limit and request of the ResourceRequirements. +func (r *ResourceRequirementsConfig) WithMemoryLimitAndRequest(memoryMi int64) *applycorev1.ResourceRequirementsApplyConfiguration { + return r. + WithRequests(corev1.ResourceList{ + corev1.ResourceMemory: fromPtr(resource.NewQuantity(memoryMi*1024*1024, resource.BinarySI)), + }). + WithLimits(corev1.ResourceList{ + corev1.ResourceMemory: fromPtr(resource.NewQuantity(memoryMi*1024*1024, resource.BinarySI)), + }) +} + +// WithCPURequest sets the CPU request of the ResourceRequirements. +func (r *ResourceRequirementsConfig) WithCPURequest(cpuM int64) *applycorev1.ResourceRequirementsApplyConfiguration { + return r.WithRequests(corev1.ResourceList{ + corev1.ResourceCPU: fromPtr(resource.NewMilliQuantity(cpuM, resource.DecimalSI)), + // Don't set CPU limits, see https://home.robusta.dev/blog/stop-using-cpu-limits + }) +} + +// VolumeConfig wraps applycorev1.VolumeApplyConfiguration. +type VolumeConfig struct { + *applycorev1.VolumeApplyConfiguration +} + +// Volume creates a new VolumeConfig. +func Volume() *VolumeConfig { + return &VolumeConfig{applycorev1.Volume()} +} + +// EmptyDirVolumeSourceConfig wraps applycorev1.EmptyDirVolumeSourceApplyConfiguration. +type EmptyDirVolumeSourceConfig struct { + *applycorev1.EmptyDirVolumeSourceApplyConfiguration +} + +// EmptyDirVolumeSource creates a new EmptyDirVolumeSourceConfig. +func EmptyDirVolumeSource() *EmptyDirVolumeSourceConfig { + return &EmptyDirVolumeSourceConfig{applycorev1.EmptyDirVolumeSource()} +} + +// Inner returns the inner applycorev1.EmptyDirVolumeSourceApplyConfiguration. +func (e *EmptyDirVolumeSourceConfig) Inner() *applycorev1.EmptyDirVolumeSourceApplyConfiguration { + return e.EmptyDirVolumeSourceApplyConfiguration +} + +// ContainerPortConfig wraps applycorev1.ContainerPortApplyConfiguration. +type ContainerPortConfig struct { + *applycorev1.ContainerPortApplyConfiguration +} + +// ContainerPort creates a new ContainerPortConfig. +func ContainerPort() *ContainerPortConfig { + return &ContainerPortConfig{applycorev1.ContainerPort()} +} + +// ServiceConfig wraps applycorev1.ServiceApplyConfiguration. +type ServiceConfig struct { + *applycorev1.ServiceApplyConfiguration +} + +// Service creates a new ServiceConfig. +func Service(name, namespace string) *ServiceConfig { + return &ServiceConfig{applycorev1.Service(name, namespace)} +} + +// ServiceSpecConfig wraps applycorev1.ServiceSpecApplyConfiguration. +type ServiceSpecConfig struct { + *applycorev1.ServiceSpecApplyConfiguration +} + +// ServiceSpec creates a new ServiceSpecConfig. +func ServiceSpec() *ServiceSpecConfig { + return &ServiceSpecConfig{applycorev1.ServiceSpec()} +} + +// ServicePortConfig wraps applycorev1.ServicePortApplyConfiguration. +type ServicePortConfig struct { + *applycorev1.ServicePortApplyConfiguration +} + +// ServicePort creates a new ServicePortConfig. +func ServicePort() *ServicePortConfig { + return &ServicePortConfig{applycorev1.ServicePort()} +} + +// NamespaceConfig wraps applycorev1.NamespaceApplyConfiguration. +type NamespaceConfig struct { + *applycorev1.NamespaceApplyConfiguration +} + +// Namespace creates a new NamespaceConfig. +func Namespace(name string) *applycorev1.NamespaceApplyConfiguration { + return applycorev1.Namespace(name) +} + +func fromPtr[T any](v *T) T { + if v != nil { + return *v + } + var zero T + return zero +} diff --git a/e2e/internal/kuberesource/writer.go b/e2e/internal/kuberesource/writer.go new file mode 100644 index 0000000000..f1d3ca9934 --- /dev/null +++ b/e2e/internal/kuberesource/writer.go @@ -0,0 +1,51 @@ +package kuberesource + +import ( + "bytes" + + "gopkg.in/yaml.v3" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// EncodeResources encodes a list of resources into a single YAML document. +func EncodeResources(resources ...any) ([]byte, error) { + unstructuredResources, err := ResourcesToUnstructured(resources) + if err != nil { + return nil, err + } + return EncodeUnstructured(unstructuredResources) +} + +// ResourcesToUnstructured converts a list of resources into a list of unstructured resources. +func ResourcesToUnstructured(resources []any) ([]*unstructured.Unstructured, error) { + var unstructuredResources []*unstructured.Unstructured + for _, r := range resources { + u, err := runtime.DefaultUnstructuredConverter.ToUnstructured(r) + if err != nil { + return nil, err + } + unstructuredResources = append(unstructuredResources, &unstructured.Unstructured{Object: u}) + } + return unstructuredResources, nil +} + +// EncodeUnstructured encodes a list of unstructured resources into a single YAML document. +func EncodeUnstructured(resources []*unstructured.Unstructured) ([]byte, error) { + var w bytes.Buffer + for i, u := range resources { + doc, err := yaml.Marshal(u.Object) + if err != nil { + return nil, err + } + if _, err := w.Write(doc); err != nil { + return nil, err + } + if i != len(resources)-1 { + if _, err := w.WriteString("---\n"); err != nil { + return nil, err + } + } + } + return w.Bytes(), nil +} diff --git a/justfile b/justfile index 784010b8c6..622e9430da 100644 --- a/justfile +++ b/justfile @@ -25,7 +25,7 @@ default_deploy_target := "simple" workspace_dir := "workspace" # Generate policies, apply Kubernetes manifests. -deploy target=default_deploy_target cli=default_cli: (generate target cli) apply +deploy target=default_deploy_target cli=default_cli: (generate target cli) (apply target) # Generate policies, update manifest. generate target=default_deploy_target cli=default_cli: @@ -33,7 +33,14 @@ generate target=default_deploy_target cli=default_cli: set -euo pipefail mkdir -p ./{{ workspace_dir }} rm -rf ./{{ workspace_dir }}/* - cp -R ./deployments/{{ target }} ./{{ workspace_dir }}/deployment + case {{ target }} in + "simple") + nix shell .#nunki --command resourcegen {{ target }} ./{{ workspace_dir }}/deployment/deployment.yml + ;; + *) + cp -R ./deployments/{{ target }} ./{{ workspace_dir }}/deployment + ;; + esac echo "{{ target }}${namespace_suffix-}" > ./{{ workspace_dir }}/just.namespace nix run .#scripts.patch-nunki-image-hashes -- ./{{ workspace_dir }}/deployment nix run .#kypatch images -- ./{{ workspace_dir }}/deployment \ @@ -51,8 +58,16 @@ generate target=default_deploy_target cli=default_cli: echo "generate $duration" >> ./{{ workspace_dir }}/just.perf # Apply Kubernetes manifests from /deployment -apply: - kubectl apply -f ./{{ workspace_dir }}/deployment/ns.yml +apply target=default_deploy_target: + #!/usr/bin/env bash + case {{ target }} in + "simple") + : + ;; + *) + kubectl apply -f ./{{ workspace_dir }}/deployment/ns.yml + ;; + esac kubectl apply -f ./{{ workspace_dir }}/deployment # Delete Kubernetes manifests. @@ -63,6 +78,10 @@ undeploy: echo "No workspace directory found, nothing to undeploy." exit 0 fi + if [[ ! -f ./{{ workspace_dir }}/just.namespace ]]; then + echo "No namespace file found, nothing to undeploy." + exit 0 + fi ns=$(cat ./{{ workspace_dir }}/just.namespace) if kubectl get ns $ns 2> /dev/null; then kubectl delete -f ./{{ workspace_dir }}/deployment diff --git a/packages/by-name/nunki/package.nix b/packages/by-name/nunki/package.nix index 0721a4679f..b5e293aa20 100644 --- a/packages/by-name/nunki/package.nix +++ b/packages/by-name/nunki/package.nix @@ -16,13 +16,15 @@ let subPackages = [ "e2e/openssl" ]; }; + + packageOutputs = [ "coordinator" "initializer" "cli" ]; in buildGoModule rec { pname = "nunki"; version = builtins.readFile ../../../version.txt; - outputs = subPackages ++ [ "out" ]; + outputs = packageOutputs ++ [ "out" ]; # The source of the main module of this repo. We filter for Go files so that # changes in the other parts of this repo don't trigger a rebuild. @@ -45,7 +47,7 @@ buildGoModule rec { proxyVendor = true; vendorHash = "sha256-VBCTnRx4BBvG/yedChE55ZQbsaFk2zDcXtXof9v3XNI="; - subPackages = [ "coordinator" "initializer" "cli" ]; + subPackages = packageOutputs ++ [ "e2e/internal/kuberesource/resourcegen" ]; prePatch = '' install -D ${lib.getExe genpolicy} cli/cmd/assets/genpolicy @@ -71,14 +73,11 @@ buildGoModule rec { ''; postInstall = '' - for sub in ${builtins.concatStringsSep " " subPackages}; do + for sub in ${builtins.concatStringsSep " " packageOutputs}; do mkdir -p "''${!sub}/bin" mv "$out/bin/$sub" "''${!sub}/bin/$sub" done - # ensure no binary is left in out - rmdir "$out/bin/" - # rename the cli binary to nunki mv "$cli/bin/cli" "$cli/bin/nunki" '';