Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/installation/kubesoloctl.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ kubesoloctl kubeconfig fetch
| Command | Purpose |
|---|---|
| `install` | Install KubeSolo and configure the service / start the container |
| `check` | Run pre-flight checks only, without installing |
| `check` | Run pre-flight checks only, without installing (`--json` runs every check and reports each result) |
| `upgrade --version=<v>` | Upgrade to a newer version, with a backup and automatic rollback |
| `rollback` | Restore the version, datastore and configuration from before the last upgrade |
| `status` | Show the running version, health, and the last upgrade or rollback |
Expand Down
31 changes: 31 additions & 0 deletions internal/cli/cmd_check.go
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
package cli

import (
"encoding/json"
"fmt"
"io"
"os"
"runtime"

"github.com/portainer/kubesolo/internal/cli/config"
"github.com/portainer/kubesolo/internal/cli/detect"
"github.com/portainer/kubesolo/internal/cli/preflight"
"github.com/portainer/kubesolo/internal/cli/ui"
"github.com/portainer/kubesolo/types"
"github.com/spf13/cobra"
)

func checkCmd(cfg *config.Config) *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "check",
Short: "Run pre-flight checks without installing",
Long: `Validates that this host meets all requirements for KubeSolo. Exits 0 on success.`,
RunE: func(cmd *cobra.Command, args []string) error {
if asJSON {
return runCheckJSON(cfg)
}
return runCheck(cfg)
},
}
f := cmd.Flags()
f.BoolVar(&asJSON, "json", false,
"Run every check, even after one fails, and print each result as JSON")
f.BoolVar(&cfg.InstallPrereqs, "install-prereqs",
envBool("KUBESOLO_INSTALL_PREREQS", false),
"Automatically install missing OS prerequisites (e.g. iptables/nftables)")
Expand Down Expand Up @@ -62,3 +71,25 @@ func runCheck(cfg *config.Config) error {
p.Done("Host is ready for KubeSolo.")
return nil
}

func runCheckJSON(cfg *config.Config) error {
checks := preflight.ContainerSuite()
if runtime.GOOS != "darwin" {
checks = preflight.Suite(cfg.InstallPrereqs, cfg.PprofServer, cfg.NodeIP)
}
return writeCheckReport(os.Stdout, preflight.Report(checks))
}

// writeCheckReport prints report and, when a check failed, returns an error
// with nothing more to say so the command exits non-zero.
func writeCheckReport(w io.Writer, report types.PreflightReport) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return err
}
if report.Failed() {
return ui.ErrAlreadyReported
}
return nil
}
74 changes: 74 additions & 0 deletions internal/cli/cmd_check_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cli

import (
"bytes"
"encoding/json"
"errors"
"testing"

"github.com/portainer/kubesolo/internal/cli/ui"
"github.com/portainer/kubesolo/types"
)

func TestWriteCheckReport(t *testing.T) {
cases := []struct {
name string
result types.PreflightResult
wantErr error
}{
{name: "pass exits zero", result: types.PreflightPass},
{name: "warn exits zero", result: types.PreflightWarn},
{name: "fail exits non-zero", result: types.PreflightFail, wantErr: ui.ErrAlreadyReported},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
report := types.PreflightReport{Checks: []types.PreflightCheck{
{Name: "a", Result: types.PreflightPass},
{Name: "b", Result: tc.result, Message: "m"},
}}
var out bytes.Buffer
if err := writeCheckReport(&out, report); !errors.Is(err, tc.wantErr) {
t.Fatalf("got error %v, want %v", err, tc.wantErr)
}

var got types.PreflightReport
dec := json.NewDecoder(&out)
if err := dec.Decode(&got); err != nil {
t.Fatalf("output is not a report: %v", err)
}
if dec.More() {
t.Error("output holds more than one JSON document")
}
if len(got.Checks) != 2 || got.Checks[1] != report.Checks[1] {
t.Errorf("got %+v, want %+v", got, report)
}
})
}
}

// Check messages are multi-line help texts, quoted hostnames and package
// commands; Portainer must read back exactly what the check said.
func TestWriteCheckReport_MessagesSurviveEncoding(t *testing.T) {
messages := []string{
"pods cannot reach the API server at 10.0.0.5:6443: i/o timeout\n\n firewalld: firewall-cmd --reload\n\tiptables -I INPUT -s 10.42.0.0/16 -j ACCEPT",
`hostname "Edge_Gateway" is not RFC 1123 compliant`,
"no supported package manager was detected — install them with your distribution's package manager",
`add "ip saddr 10.42.0.0/16 accept" & reload <nftables>`,
}
report := types.PreflightReport{}
for _, m := range messages {
report.Checks = append(report.Checks, types.PreflightCheck{Name: "c", Result: types.PreflightFail, Message: m})
}

var out bytes.Buffer
_ = writeCheckReport(&out, report)
var got types.PreflightReport
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("output is not a report: %v\n%s", err, out.String())
}
for i, m := range messages {
if got.Checks[i].Message != m {
t.Errorf("message %d: got %q, want %q", i, got.Checks[i].Message, m)
}
}
}
42 changes: 36 additions & 6 deletions internal/cli/preflight/preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package preflight

import (
"bufio"
"errors"
"fmt"
"net"
"os"
Expand All @@ -14,6 +15,7 @@ import (
"strings"

"github.com/portainer/kubesolo/internal/runtime/network"
"github.com/portainer/kubesolo/types"
"github.com/rs/zerolog/log"
)

Expand Down Expand Up @@ -43,19 +45,48 @@ func Suite(installPrereqs, pprofServer bool, nodeIP string) []Check {
}
}

// Warning is returned by a check that found a problem the install can carry on
// past.
type Warning string

func (w Warning) Error() string { return string(w) }

// RunSuite executes every check in suite, stopping on the first failure.
// Each passing check emits one detail line; the caller owns the enclosing
// step/ok Printer calls.
// Each passing check emits one detail line, after its warning if it has one;
// the caller owns the enclosing step/ok Printer calls.
func RunSuite(checks []Check) error {
for _, c := range checks {
if err := c.Run(); err != nil {
err := c.Run()
var w Warning
if errors.As(err, &w) {
log.Warn().Msg(w.Error())
} else if err != nil {
return fmt.Errorf("%s: %w", c.Name, err)
}
log.Info().Msgf("%s", c.Name)
}
return nil
}

// Report executes every check in suite, carrying on past failures, and
// returns each one's result.
func Report(checks []Check) types.PreflightReport {
report := types.PreflightReport{Checks: make([]types.PreflightCheck, 0, len(checks))}
for _, c := range checks {
result := types.PreflightCheck{Name: c.Name, Result: types.PreflightPass}
err := c.Run()
var w Warning
switch {
case errors.As(err, &w):
result.Result, result.Message = types.PreflightWarn, err.Error()
case err != nil:
result.Result, result.Message = types.PreflightFail, err.Error()
}
report.Checks = append(report.Checks, result)
}
return report
}

// CheckRoot verifies the installer is running as the root user (UID 0).
func CheckRoot() error {
if os.Getuid() != 0 {
Expand Down Expand Up @@ -279,11 +310,10 @@ func CheckAppArmor(installPrereqs bool) error {
if manager != "" {
hint = "run: " + strings.Join(append(append([]string{manager}, installArgs...), pkg), " ")
}
log.Warn().Msgf(
return Warning(fmt.Sprintf(
"AppArmor is enabled but apparmor_parser is missing: containers will run unconfined, "+
"and pods that request an AppArmor profile will fail to start. "+
"Re-run with --install-prereqs to install it, or %s", hint)
return nil
"Re-run with --install-prereqs to install it, or %s", hint))
}

// appArmorEnabled reports whether the running kernel has AppArmor enabled.
Expand Down
Loading
Loading