Skip to content
Open
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
16 changes: 15 additions & 1 deletion cmd/microshift/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,23 @@ import (

func main() {
command := newCommand()
code := cli.Run(command)
code := runCommand(command, os.Args[1:])
os.Exit(code)
}

func runCommand(command *cobra.Command, args []string) int {
// Certificate commands own their JSON/YAML error output. Keep the existing
// logging and diagnostic behavior for every other command.
selected, _, _ := command.Find(args)
for current := selected; current != nil; current = current.Parent() {
if current.Name() == "certs" {
return cmds.RunCertsCommand(command, args)
}
}
command.SetArgs(args)
return cli.Run(command)
}

func newCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "microshift",
Expand All @@ -41,6 +54,7 @@ func newCommand() *cobra.Command {
cmd.AddCommand(cmds.NewBackupCommand())
cmd.AddCommand(cmds.NewRestoreCommand())
cmd.AddCommand(cmds.NewHealthcheckCommand())
cmd.AddCommand(cmds.NewCertsCommand(ioStreams))
cmd.AddCommand(cmds.NewAddNodeCommand())
cmd.AddCommand(cmds.NewC2CCProbeCommand())
return cmd
Expand Down
182 changes: 182 additions & 0 deletions cmd/microshift/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//nolint:testpackage // Exercise the unexported executable dispatch boundary.
package main

import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"os"
"os/exec"
"slices"
"strings"
"testing"
"time"

"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"k8s.io/klog/v2"
"sigs.k8s.io/yaml"

certificatesv1alpha1 "github.com/openshift/microshift/pkg/apis/certificates/v1alpha1"
)

func TestRunCommandCertificateErrors(t *testing.T) {
for _, format := range []string{"", "json", "yaml"} {
for _, arguments := range [][]string{
{"certs", "status", "unexpected"},
{"certs", "status", "--invalid-status-flag"},
{"certs", "status", "--help", "--invalid-status-flag"},
{"certs", "status", "-h", "--invalid-status-flag"},
} {
t.Run(format+"/"+strings.Join(arguments, " "), func(t *testing.T) {
args := slices.Clone(arguments)
if format != "" {
args = append(args, "-o", format)
}
stdout, stderr, code := executeRunCommand(t, args...)
require.Equal(t, 1, code)
require.Empty(t, stdout)
require.NotContains(t, stderr, "Usage:")
if format == "" {
require.True(t, strings.HasPrefix(stderr, "Error: "))
return
}
data := []byte(stderr)
if format == "yaml" {
var err error
data, err = yaml.YAMLToJSONStrict(data)
require.NoError(t, err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
var document certificatesv1alpha1.Error
require.NoError(t, decoder.Decode(&document))
require.ErrorIs(t, decoder.Decode(new(any)), io.EOF)
require.Equal(t, certificatesv1alpha1.APIVersion, document.APIVersion)
require.Equal(t, certificatesv1alpha1.ErrorKind, document.Kind)
require.Equal(t, certificatesv1alpha1.ErrorCodeInvalidArguments, document.Code)
require.NotEmpty(t, document.Message)
require.False(t, document.GeneratedAt.IsZero())
require.Nil(t, document.Details)
})
}
}
}

func TestRunCommandOtherCommands(t *testing.T) {
stdout, stderr, code := executeRunCommand(t, "certs", "status", "--help")
require.Zero(t, code)
require.Empty(t, stderr)
require.Contains(t, stdout, "Report the status of managed MicroShift certificates")

stdout, stderr, code = executeRunCommand(t, "version", "--invalid-version-flag", "-o", "json")
require.Equal(t, 1, code)
require.Empty(t, stdout)
require.Contains(t, stderr, "unknown flag: --invalid-version-flag")
require.NotContains(t, stderr, certificatesv1alpha1.APIVersion)
}

func TestRunCommandCertificateHooks(t *testing.T) {
for _, hook := range []string{"injected", "root", "root-error"} {
for _, format := range []string{"json", "yaml"} {
t.Run(hook+"/"+format, func(t *testing.T) {
stdout, stderr, code := executeRunCommandWithEnv(t,
[]string{"MICROSHIFT_TEST_CERTIFICATE_HOOK=" + hook}, "certs", "status", "-o", format)
if hook != "root-error" {
require.Zero(t, code)
require.Equal(t, "command ran\n", stdout)
require.Empty(t, stderr, "root initialization must not add logs to structured output")
return
}
require.Equal(t, 1, code)
require.Empty(t, stdout)
data := []byte(stderr)
if format == "yaml" {
var err error
data, err = yaml.YAMLToJSONStrict(data)
require.NoError(t, err)
}
decoder := json.NewDecoder(bytes.NewReader(data))
var document certificatesv1alpha1.Error
require.NoError(t, decoder.Decode(&document))
require.ErrorIs(t, decoder.Decode(new(any)), io.EOF)
require.Equal(t, certificatesv1alpha1.ErrorKind, document.Kind)
require.Equal(t, "root hook failed", document.Message)
})
}
}
}

func executeRunCommand(t *testing.T, args ...string) (string, string, int) {
t.Helper()
return executeRunCommandWithEnv(t, nil, args...)
}

func executeRunCommandWithEnv(t *testing.T, env []string, args ...string) (string, string, int) {
t.Helper()
executable, err := os.Executable()
require.NoError(t, err)
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
command := exec.CommandContext(ctx, executable, append([]string{"-test.run=^TestRunCommandHelperProcess$", "--"}, args...)...)
command.Env = append(os.Environ(), "MICROSHIFT_TEST_RUN_COMMAND=1")
command.Env = append(command.Env, env...)
var stdout, stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
err = command.Run()
require.NoError(t, ctx.Err())
if err != nil {
var exitError *exec.ExitError
require.ErrorAs(t, err, &exitError)
}
return stdout.String(), stderr.String(), command.ProcessState.ExitCode()
}

func TestRunCommandHelperProcess(t *testing.T) {
if os.Getenv("MICROSHIFT_TEST_RUN_COMMAND") != "1" {
return
}
separator := slices.Index(os.Args, "--")
require.NotEqual(t, -1, separator)
command := newCommand()
if hook := os.Getenv("MICROSHIFT_TEST_CERTIFICATE_HOOK"); hook != "" {
configureCertificateHookTest(t, command, hook)
}
os.Exit(runCommand(command, os.Args[separator+1:]))
}

func configureCertificateHookTest(t *testing.T, root *cobra.Command, hook string) {
t.Helper()
log.SetFlags(log.LstdFlags)
rootCalls := 0
if hook != "injected" {
root.PersistentPreRunE = func(*cobra.Command, []string) error {
rootCalls++
klog.Info("root initialization diagnostic")
if hook == "root-error" {
return errors.New("root hook failed")
}
return nil
}
}
status, _, err := root.Find([]string{"certs", "status"})
require.NoError(t, err)
require.NotNil(t, status.PreRunE, "privilege checks belong to the executable subcommand")
// Stub privilege and inventory work so this subprocess test is independent
// of the invoking user's permissions and host configuration.
status.PreRunE = func(*cobra.Command, []string) error {
require.NotEqual(t, "root-error", hook, "root failure must stop execution")
require.Zero(t, log.Flags(), "the injected root hook must initialize logging first")
if hook == "root" {
require.Equal(t, 1, rootCalls)
}
return nil
}
status.RunE = func(command *cobra.Command, _ []string) error {
command.Println("command ran")
return nil
}
}
2 changes: 1 addition & 1 deletion docs/contributor/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ This document describes how MicroShift is built and how its components interact

| Package | Purpose |
|---------|---------|
| `pkg/cmd/` | Cobra CLI commands: `run`, `version`, `show-config`, `backup`, `restore`, `healthcheck`, `c2cc-probe` |
| `pkg/cmd/` | Cobra CLI commands: `run`, `version`, `show-config`, `backup`, `restore`, `healthcheck`, `c2cc-probe`, `certs` |
| `pkg/controllers/` | Kubernetes control plane service wrappers — etcd, kube-apiserver, kube-controller-manager, kube-scheduler, kubelet, OpenShift controllers |
| `pkg/servicemanager/` | Dependency-aware service lifecycle manager with startup recording |
| `pkg/admin/` | Backup/restore, data management, pre-run checks (version metadata, feature gates, health verification) |
Expand Down
1 change: 1 addition & 0 deletions docs/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ List of the documents in alphabetical file name order.
- [AMQ Broker on MicroShift](./howto_amq_broker.md)
- [Cluster-to-Cluster Connectivity (C2CC)](./howto_c2cc.md)
- [Encrypting C2CC Traffic with IPsec](./howto_c2cc_ipsec.md)
- [Inspecting MicroShift Certificates](./howto_certificates.md)
- [CIS Level 2 Hardening for MicroShift](./howto_cis_hardening.md)
- [MicroShift Configuration](./howto_config.md)
- [Firewall Configuration](./howto_firewall.md)
Expand Down
97 changes: 97 additions & 0 deletions docs/user/howto_certificates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Inspecting MicroShift Certificate Status

Run `microshift certs status` as root to inspect the certificates managed by
MicroShift, including CAs, serving certificates, client certificates, and peer
certificates:

```bash
sudo microshift certs status
```

The command reads the local configuration and existing certificates under
`/var/lib/microshift/certs`. It does not create, renew, or repair certificates,
restart services, or require the Kubernetes API to be available. MicroShift must
have initialized its certificates first. This is not an inventory of certificates
managed by workloads or supplied externally by users.

## Output Formats

Without an output flag, the command prints a table with `SERVICE`, `CERTIFICATE`,
`STATUS`, `EXPIRY`, and `MESSAGE` columns. Expiry times are UTC. Status is
`Healthy`, `ExpiresSoon`, `ExpirationImminent`, `Expired`, or `NotYetValid`;
the message distinguishes certificates that are not expiring, expiring, expired,
or not yet valid.

Healthy certificates use a concise message, for example `Valid for 300 days`.
`ExpiresSoon` reports that remaining validity is at or below the warning
threshold; `ExpirationImminent`
reports the critical threshold instead. Thresholds use the certificate's actual
lifetime and rotation policy, not a fixed number of days. Displayed day counts
are rounded up; status comparisons use the unrounded values. Expired and
not-yet-valid messages do not include thresholds.

For automation, select JSON or YAML with `-o` or `--output`:

```bash
sudo microshift certs status -o json
sudo microshift certs status --output=yaml
```

Both formats emit one `CertificateStatusList` document on stdout, with
`apiVersion: microshift.openshift.io/v1alpha1`. They contain the same fields:

- `generatedAt`: the timestamp used for the report's calculations.
- `config`: the effective certificate policy, including
`forceRestartOnExpirationImminent` and default serving/CA validity durations.
- `items`: certificates sorted by service and then name. Each item contains
`service`, `name`, `role`, `rotationPolicy`, `status`, `notBefore`, `notAfter`,
and `remainingSeconds`. The `status` field uses the same five state names as the
table. Remaining seconds are zero at expiry and negative afterward.
- `warnings`: configuration warnings, or an empty array when none apply.

For currently valid certificates, the `standard` rotation policy is `Healthy`
above 58.3% remaining validity, `ExpiresSoon` above 33.3%, and
`ExpirationImminent` otherwise. The `extended` policy uses 15% and 10% thresholds.
At or after `notAfter`, certificates are `Expired`. Before `notBefore`, they are
`NotYetValid`, with a `Valid in ...` message in the table. `NotYetValid` is an
unhealthy state: check clock synchronization and certificate issuance before
deciding whether renewal is needed. It does not mean expiration is imminent.
A successful report exits with code 0 regardless of certificate state;
automation should inspect the reported `status` values.

The report contains certificate metadata, not certificate PEM data or private
keys. Go consumers can use the exported types and `AddToScheme` in
`github.com/openshift/microshift/pkg/apis/certificates/v1alpha1` to decode the
versioned documents.

## Warnings and Errors

In table mode, configuration warnings are written to stderr with a `WARNING:`
prefix. In JSON/YAML mode, warnings are included in the document's `warnings`
array, leaving stderr empty on success.

Failures exit non-zero. In table mode, stderr contains a human-readable error.
With `-o json` or `-o yaml`, failures leave stdout empty and write exactly one
versioned `Error` document to stderr, without usage text or additional diagnostics.
For example, invalid configuration produces this JSON shape:

```json
{
"apiVersion": "microshift.openshift.io/v1alpha1",
"kind": "Error",
"generatedAt": "2026-09-25T12:00:00Z",
"code": "InvalidConfiguration",
"message": "failed to load MicroShift configuration; check /etc/microshift/config.yaml and /etc/microshift/config.d",
"details": null
}
```

Use `code`, rather than parsing `message`, to classify failures. Status error
codes include `InvalidArguments`, `InsufficientPrivileges`,
`InvalidConfiguration`, `CertificateInventoryFailed`, and `InternalError`.
Configuration errors deliberately omit the underlying loader diagnostic because
it can contain raw configuration or credentials. Inspect the configuration files
locally without copying sensitive values into logs.

Only `json` and `yaml` are accepted values for the output flag; omit the flag for
the table. An unsupported output format is rejected with a human-readable error.
12 changes: 12 additions & 0 deletions pkg/apis/certificates/v1alpha1/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Package v1alpha1 defines the versioned output documents for microshift certs.
// These Kubernetes API objects are registered in a local scheme for CLI
// serialization, but are not served or persisted by the API server.
// JSON and YAML use the same field names and value types.
// Kubebuilder markers describe schema constraints; unmarshalling alone does
// not enforce them.
//
// +kubebuilder:validation:Required
// +kubebuilder:object:generate=true
// +groupName=microshift.openshift.io
// +k8s:deepcopy-gen=package
package v1alpha1
25 changes: 25 additions & 0 deletions pkg/apis/certificates/v1alpha1/groupversion_info.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package v1alpha1

import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)

var (
GroupName = "microshift.openshift.io"
GroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"}

SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
AddToScheme = SchemeBuilder.AddToScheme
)

func addKnownTypes(scheme *runtime.Scheme) error {
scheme.AddKnownTypes(GroupVersion,
&CertificateStatusList{},
&CertificateRenewalResult{},
&Error{},
)
metav1.AddToGroupVersion(scheme, GroupVersion)
return nil
}
Loading