feat: add production hardening — Ed25519 signing, Portainer Edge, SSH extension (Phase 4)
Image signing: - Ed25519 sign/verify package (pure Go stdlib, zero deps) - genkey and sign CLI subcommands for build system - Optional --pubkey flag for verifying updates on apply - Signature URLs in update metadata (latest.json) Portainer Edge Agent: - cloud-init portainer.go module writes K8s manifest - Auto-deploys Edge Agent when portainer.edge-agent.enabled - Full RBAC (ServiceAccount, ClusterRoleBinding, Deployment) - 5 Portainer tests in portainer_test.go Production tooling: - SSH debug extension builder (hack/build-ssh-extension.sh) - Boot performance benchmark (test/benchmark/bench-boot.sh) - Resource usage benchmark (test/benchmark/bench-resources.sh) - Deployment guide (docs/deployment-guide.md) Test results: 50 update agent tests + 22 cloud-init tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -92,7 +92,12 @@ func cmdApply(configPath string) error {
|
||||
return fmt.Errorf("kubesolo config: %w", err)
|
||||
}
|
||||
|
||||
// 4. Save persistent configs for next boot
|
||||
// 4. Apply Portainer Edge Agent manifest (if enabled)
|
||||
if err := cloudinit.ApplyPortainer(cfg, "/var/lib/kubesolo/server/manifests"); err != nil {
|
||||
return fmt.Errorf("portainer edge agent: %w", err)
|
||||
}
|
||||
|
||||
// 5. Save persistent configs for next boot
|
||||
if err := cloudinit.SaveHostname(cfg, persistDataDir+"/etc-kubesolo"); err != nil {
|
||||
slog.Warn("failed to save hostname", "error", err)
|
||||
}
|
||||
|
||||
136
cloud-init/portainer.go
Normal file
136
cloud-init/portainer.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package cloudinit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ApplyPortainer writes the Portainer Edge Agent deployment manifest
|
||||
// based on cloud-init config. The manifest is applied by KubeSolo after
|
||||
// the cluster is ready.
|
||||
func ApplyPortainer(cfg *Config, manifestDir string) error {
|
||||
if !cfg.Portainer.EdgeAgent.Enabled {
|
||||
slog.Info("portainer edge agent not enabled, skipping")
|
||||
return nil
|
||||
}
|
||||
|
||||
ea := cfg.Portainer.EdgeAgent
|
||||
if ea.EdgeID == "" || ea.EdgeKey == "" {
|
||||
return fmt.Errorf("portainer edge-agent enabled but edge-id and edge-key are required")
|
||||
}
|
||||
if ea.PortainerURL == "" {
|
||||
return fmt.Errorf("portainer edge-agent enabled but portainer-url is required")
|
||||
}
|
||||
|
||||
image := ea.Image
|
||||
if image == "" {
|
||||
image = "portainer/agent:latest"
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(manifestDir, 0o755); err != nil {
|
||||
return fmt.Errorf("creating manifest dir: %w", err)
|
||||
}
|
||||
|
||||
manifest := buildEdgeAgentManifest(ea.EdgeID, ea.EdgeKey, ea.PortainerURL, image)
|
||||
dest := filepath.Join(manifestDir, "portainer-edge-agent.yaml")
|
||||
if err := os.WriteFile(dest, []byte(manifest), 0o644); err != nil {
|
||||
return fmt.Errorf("writing edge agent manifest: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("portainer edge agent manifest written", "path", dest)
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildEdgeAgentManifest(edgeID, edgeKey, portainerURL, image string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# Auto-generated by KubeSolo OS cloud-init\n")
|
||||
sb.WriteString("# Portainer Edge Agent deployment\n")
|
||||
sb.WriteString("---\n")
|
||||
sb.WriteString("apiVersion: v1\n")
|
||||
sb.WriteString("kind: Namespace\n")
|
||||
sb.WriteString("metadata:\n")
|
||||
sb.WriteString(" name: portainer\n")
|
||||
sb.WriteString(" labels:\n")
|
||||
sb.WriteString(" app.kubernetes.io/name: portainer-agent\n")
|
||||
sb.WriteString(" app.kubernetes.io/component: edge-agent\n")
|
||||
sb.WriteString("---\n")
|
||||
sb.WriteString("apiVersion: v1\n")
|
||||
sb.WriteString("kind: ServiceAccount\n")
|
||||
sb.WriteString("metadata:\n")
|
||||
sb.WriteString(" name: portainer-sa-clusteradmin\n")
|
||||
sb.WriteString(" namespace: portainer\n")
|
||||
sb.WriteString("---\n")
|
||||
sb.WriteString("apiVersion: rbac.authorization.k8s.io/v1\n")
|
||||
sb.WriteString("kind: ClusterRoleBinding\n")
|
||||
sb.WriteString("metadata:\n")
|
||||
sb.WriteString(" name: portainer-crb-clusteradmin\n")
|
||||
sb.WriteString("roleRef:\n")
|
||||
sb.WriteString(" apiGroup: rbac.authorization.k8s.io\n")
|
||||
sb.WriteString(" kind: ClusterRole\n")
|
||||
sb.WriteString(" name: cluster-admin\n")
|
||||
sb.WriteString("subjects:\n")
|
||||
sb.WriteString(" - kind: ServiceAccount\n")
|
||||
sb.WriteString(" name: portainer-sa-clusteradmin\n")
|
||||
sb.WriteString(" namespace: portainer\n")
|
||||
sb.WriteString("---\n")
|
||||
sb.WriteString("apiVersion: apps/v1\n")
|
||||
sb.WriteString("kind: Deployment\n")
|
||||
sb.WriteString("metadata:\n")
|
||||
sb.WriteString(" name: portainer-agent\n")
|
||||
sb.WriteString(" namespace: portainer\n")
|
||||
sb.WriteString(" labels:\n")
|
||||
sb.WriteString(" app.kubernetes.io/name: portainer-agent\n")
|
||||
sb.WriteString(" app.kubernetes.io/component: edge-agent\n")
|
||||
sb.WriteString("spec:\n")
|
||||
sb.WriteString(" replicas: 1\n")
|
||||
sb.WriteString(" selector:\n")
|
||||
sb.WriteString(" matchLabels:\n")
|
||||
sb.WriteString(" app: portainer-agent\n")
|
||||
sb.WriteString(" template:\n")
|
||||
sb.WriteString(" metadata:\n")
|
||||
sb.WriteString(" labels:\n")
|
||||
sb.WriteString(" app: portainer-agent\n")
|
||||
sb.WriteString(" spec:\n")
|
||||
sb.WriteString(" serviceAccountName: portainer-sa-clusteradmin\n")
|
||||
sb.WriteString(" containers:\n")
|
||||
sb.WriteString(" - name: agent\n")
|
||||
sb.WriteString(fmt.Sprintf(" image: %s\n", image))
|
||||
sb.WriteString(" env:\n")
|
||||
sb.WriteString(" - name: EDGE\n")
|
||||
sb.WriteString(" value: \"1\"\n")
|
||||
sb.WriteString(" - name: EDGE_ID\n")
|
||||
sb.WriteString(fmt.Sprintf(" value: \"%s\"\n", edgeID))
|
||||
sb.WriteString(" - name: EDGE_KEY\n")
|
||||
sb.WriteString(fmt.Sprintf(" value: \"%s\"\n", edgeKey))
|
||||
sb.WriteString(" - name: EDGE_INSECURE_POLL\n")
|
||||
sb.WriteString(" value: \"1\"\n")
|
||||
sb.WriteString(" - name: KUBERNETES_POD_IP\n")
|
||||
sb.WriteString(" valueFrom:\n")
|
||||
sb.WriteString(" fieldRef:\n")
|
||||
sb.WriteString(" fieldPath: status.podIP\n")
|
||||
sb.WriteString(" ports:\n")
|
||||
sb.WriteString(" - containerPort: 9001\n")
|
||||
sb.WriteString(" protocol: TCP\n")
|
||||
sb.WriteString(" resources:\n")
|
||||
sb.WriteString(" requests:\n")
|
||||
sb.WriteString(" memory: 64Mi\n")
|
||||
sb.WriteString(" cpu: 50m\n")
|
||||
sb.WriteString(" limits:\n")
|
||||
sb.WriteString(" memory: 256Mi\n")
|
||||
sb.WriteString(" cpu: 500m\n")
|
||||
sb.WriteString(" volumeMounts:\n")
|
||||
sb.WriteString(" - name: docker-certs\n")
|
||||
sb.WriteString(" mountPath: /certs\n")
|
||||
sb.WriteString(" readOnly: true\n")
|
||||
sb.WriteString(" volumes:\n")
|
||||
sb.WriteString(" - name: docker-certs\n")
|
||||
sb.WriteString(" emptyDir: {}\n")
|
||||
sb.WriteString(" tolerations:\n")
|
||||
sb.WriteString(" - operator: Exists\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
136
cloud-init/portainer_test.go
Normal file
136
cloud-init/portainer_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package cloudinit
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyPortainerDisabled(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ApplyPortainer(cfg, dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// No manifest should be written
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Errorf("expected no files, got %d", len(entries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPortainerMissingFields(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Portainer: PortainerConfig{
|
||||
EdgeAgent: EdgeAgentConfig{
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
err := ApplyPortainer(cfg, dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing edge-id and edge-key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPortainerMissingURL(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Portainer: PortainerConfig{
|
||||
EdgeAgent: EdgeAgentConfig{
|
||||
Enabled: true,
|
||||
EdgeID: "test-id",
|
||||
EdgeKey: "test-key",
|
||||
},
|
||||
},
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
err := ApplyPortainer(cfg, dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing portainer-url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPortainerEnabled(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Portainer: PortainerConfig{
|
||||
EdgeAgent: EdgeAgentConfig{
|
||||
Enabled: true,
|
||||
EdgeID: "test-edge-id",
|
||||
EdgeKey: "test-edge-key-abc123",
|
||||
PortainerURL: "https://portainer.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ApplyPortainer(cfg, dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Check manifest was created
|
||||
manifestPath := filepath.Join(dir, "portainer-edge-agent.yaml")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
t.Fatalf("manifest not created: %v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
// Check key elements are present
|
||||
checks := []string{
|
||||
"kind: Namespace",
|
||||
"name: portainer",
|
||||
"kind: Deployment",
|
||||
"name: portainer-agent",
|
||||
"EDGE_ID",
|
||||
"test-edge-id",
|
||||
"EDGE_KEY",
|
||||
"test-edge-key-abc123",
|
||||
"image: portainer/agent:latest",
|
||||
"kind: ClusterRoleBinding",
|
||||
"kind: ServiceAccount",
|
||||
}
|
||||
|
||||
for _, check := range checks {
|
||||
if !strings.Contains(content, check) {
|
||||
t.Errorf("manifest missing expected content: %q", check)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyPortainerCustomImage(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Portainer: PortainerConfig{
|
||||
EdgeAgent: EdgeAgentConfig{
|
||||
Enabled: true,
|
||||
EdgeID: "test-id",
|
||||
EdgeKey: "test-key",
|
||||
PortainerURL: "https://portainer.example.com",
|
||||
Image: "portainer/agent:2.20.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
if err := ApplyPortainer(cfg, dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "portainer-edge-agent.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "image: portainer/agent:2.20.0") {
|
||||
t.Error("expected custom image in manifest")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user