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
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ make lint
make coverage
```

Requires Go 1.26.1+ and SSH key-based auth for remote nodes.
Requires Go 1.26.1+ and SSH key-based auth for remote nodes. Use the latest
1.26 patch release for CI, release, and local source builds.

## High-Level Shape

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
go-version: "1.26.2"

- name: Download dependencies
run: go mod download
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/govulncheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
go-version: "1.26.2"

- name: Download dependencies
run: go mod download
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/hardware-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
go-version: "1.26.2"

- name: Download dependencies
run: go mod download
Expand Down Expand Up @@ -124,7 +124,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
go-version: "1.26.2"

- name: Download dependencies
run: go mod download
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6.4.0
with:
go-version-file: go.mod
go-version: "1.26.2"

- name: Download dependencies
run: go mod download
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ make coverage # ./hack/coverage-check.sh
make clean # rm -f axis
```

Requires Go 1.26.1+ (`go.mod` is authoritative). Remote node tests require SSH
Requires Go 1.26.1+ (`go.mod` is authoritative for the minimum; use the latest
1.26 patch release). Remote node tests require SSH
key-based auth.

### CI Pipeline
Expand Down
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ GOVERSION := $(shell go version | awk '{print $$3}')
LDFLAGS := -s -w \
-X github.com/toasterbook88/axis/internal/buildinfo.Commit=$(COMMIT) \
-X github.com/toasterbook88/axis/internal/buildinfo.Date=$(DATE) \
-X github.com/toasterbook88/axis/internal/buildinfo.GoVersion=$(GOVERSION)
-X github.com/toasterbook88/axis/internal/buildinfo.GoVersion=$(GOVERSION) \
-X github.com/toasterbook88/axis/internal/buildinfo.UpdateManagedBy=

.PHONY: build test test-race lint coverage clean install

Expand Down
82 changes: 67 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,72 @@ These files are local operator memory, not authoritative cluster truth. AXIS now

## Installation

**Using `go install` (recommended):**
### 1. Quick Install (macOS / Linux)

The fastest way to install AXIS is using our install script. It automatically detects your OS/Arch, downloads the latest release tarball securely verified against `checksums.txt`, and places it in `~/.local/bin`.

```bash
curl -fsSL -o install.sh https://raw.githubusercontent.com/toasterbook88/axis/main/install.sh
less install.sh
bash install.sh
rm -f install.sh
```

### 2. Nix Flakes (NixOS / macOS)

AXIS offers native, reproducible Nix support.

```bash
# Install to your profile
nix profile install github:toasterbook88/axis

# Or run instantly without installing
nix run github:toasterbook88/axis
```

*Note for contributors*: Run `nix develop` to enter a reproducible `devShell` containing the matching Go toolchain and required utilities.

### 3. Homebrew (macOS / Linux)

> Note: Homebrew Tap automation is currently pending. For now, please use the Quick Install script above.

### For Developers (Build from Source)

If you need unreleased `main`-branch changes or specifically want to use the Go ecosystem, you can compile from source. **Requirements:** Go 1.26.1+ (use the latest 1.26 patch release), SSH key-based auth for remote nodes.

**Using `go install`:**

```bash
go install github.com/toasterbook88/axis/cmd/axis@latest
```

`@latest` resolves to the newest published tagged release. If you need unreleased `main`-branch changes, build from source from a specific commit instead of pinning an unpublished tag.
**Manual Compilation:**

```bash
git clone https://github.com/toasterbook88/axis.git
cd axis
go build -o axis ./cmd/axis/
# Optional: move to $PATH
mv axis /usr/local/bin/axis
```

### Updating AXIS

AXIS includes a built-in self-updater via the `axis update` command.

> [!WARNING]
> **If you installed AXIS via a package manager (like Nix or Homebrew), DO NOT use `axis update` to upgrade the binary.** Doing so attempts an in-place executable swap which violates immutable system paths and desyncs your package manager's state. AXIS is package-manager aware and will politely refuse to upgrade itself, instructing you to use your respective tool (e.g. `nix profile upgrade`, `brew upgrade`).

To safely check if there is a newer version available without triggering an upgrade, run:

```bash
axis update --check
```

**Important Notes for `axis update` on Quick Install / Source builds:**
By default, `axis update` safely scopes its upgrade *only* to the currently executing binary to prevent cross-contamination in mixed dev/prod `PATH` environments. If you want it to automatically upgrade all other `axis` binaries it finds in your `$PATH`, pass the `--all` flag.

## Release Pipeline & Security

**Tagged release pipeline:**

Expand All @@ -132,18 +191,6 @@ go install github.com/toasterbook88/axis/cmd/axis@latest
- Private vulnerability reporting and automated security fixes are enabled on GitHub
- Security reporting guidance lives in [SECURITY.md](SECURITY.md)

**Build from source:**

```bash
git clone https://github.com/toasterbook88/axis.git
cd axis
go build -o axis ./cmd/axis/
# Optional: move to $PATH
mv axis /usr/local/bin/axis
```

**Requirements:** Go 1.26.1+, SSH key-based auth for remote nodes.

## Usage

### `axis facts` — local machine snapshot
Expand Down Expand Up @@ -260,7 +307,8 @@ axis daemon restart
### `axis update` — self-update to the latest release

```bash
axis update # download and install the latest release
axis update # download and install the latest release (only replaces current binary)
axis update --all # replace ALL discovered `axis` binaries in your $PATH
axis update --check # report whether an update is available (no download)
```

Expand All @@ -269,6 +317,10 @@ binary, verifies its SHA-256 checksum against the release's `checksums.txt`,
and replaces the current binary in-place. The checksum is always verified —
the release workflow produces `checksums.txt` alongside every archive.

Note: if your copy of `axis` was installed using a package manager like `nix`
or `homebrew`, `axis update` will refuse to perform an in-place replacement
and will direct you to update using your package manager natively instead.

## Configuration Reference

`~/.axis/nodes.yaml` fields:
Expand Down
30 changes: 21 additions & 9 deletions cmd/axis/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,33 @@ var doctorCheckNodeSSH = func(ctx context.Context, node config.NodeConfig) error
}

func doctorCmd() *cobra.Command {
return &cobra.Command{
var strict bool
cmd := &cobra.Command{
Use: "doctor",
Short: "Validate configuration, SSH connectivity, and daemon health",
RunE: func(cmd *cobra.Command, args []string) error {
return runDoctor(cmd)
return runDoctor(cmd, strict)
},
}
cmd.Flags().BoolVar(&strict, "strict", false, "treat daemon cache availability as a required check")
return cmd
}

func runDoctor(cmd *cobra.Command) error {
func runDoctor(cmd *cobra.Command, strict bool) error {
out := cmd.OutOrStdout()
fmt.Fprintln(out, ui.Bold("AXIS Doctor"))
fmt.Fprintln(out)

allOK := true
coreFailures := 0
advisoryWarnings := 0

// 1. Config check
cfgPath := doctorConfigPath()
fmt.Fprintf(out, "%s Config: %s\n", ui.Cyan("→"), cfgPath)
cfg, err := loadDoctorConfig(cfgPath)
if err != nil {
ui.FprintError(out, fmt.Sprintf("Config: %v", err), "cp nodes.example.yaml ~/.axis/nodes.yaml")
allOK = false
coreFailures++
} else {
fmt.Fprintf(out, " %s Loaded %d node(s)\n", ui.StatusIcon(true), len(cfg.Nodes))

Expand All @@ -64,7 +68,7 @@ func runDoctor(cmd *cobra.Command) error {
cancel()
if sshErr != nil {
fmt.Fprintf(out, " %s %s (%s): %v\n", ui.StatusIcon(false), n.Name, addr, sshErr)
allOK = false
coreFailures++
} else {
fmt.Fprintf(out, " %s %s (%s)\n", ui.StatusIcon(true), n.Name, addr)
}
Expand All @@ -81,6 +85,11 @@ func runDoctor(cmd *cobra.Command) error {
if daemonErr != nil || snap == nil {
fmt.Fprintf(out, " %s Not reachable at %s\n", ui.StatusIcon(false), daemonAddr)
fmt.Fprintf(out, " %s\n", ui.Dim("hint: start with: axis serve"))
if strict {
coreFailures++
} else {
advisoryWarnings++
}
} else {
fmt.Fprintf(out, " %s Reachable, %d node(s) cached\n",
ui.StatusIcon(true), len(snap.Nodes))
Expand All @@ -94,10 +103,13 @@ func runDoctor(cmd *cobra.Command) error {
fmt.Fprintf(out, " %s %s\n", ui.Dim("version:"), Version)

fmt.Fprintln(out)
if allOK {
ui.FprintSuccess(out, "All checks passed")
} else {
switch {
case coreFailures > 0:
ui.FprintWarning(out, "Some checks failed (see above)")
case advisoryWarnings > 0:
ui.FprintWarning(out, "Core checks passed with advisory warnings")
default:
ui.FprintSuccess(out, "All checks passed")
}
return nil
}
89 changes: 89 additions & 0 deletions cmd/axis/doctor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,95 @@ func TestDoctorReportsHealthySSHAndDaemon(t *testing.T) {
}
}

func TestDoctorTreatsDaemonFailureAsAdvisoryByDefault(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "nodes.yaml")
restorePath := stubDoctorConfigPath(t, func() string {
return tmpFile
})
defer restorePath()

restoreLoad := stubDoctorConfigLoader(t, func(string) (*config.Config, error) {
return &config.Config{
Nodes: []config.NodeConfig{
{Name: "alpha", Hostname: "alpha.local", SSHUser: "axis"},
},
}, nil
})
defer restoreLoad()

restoreSSH := stubDoctorSSHChecker(t, func(context.Context, config.NodeConfig) error {
return nil
})
defer restoreSSH()

restoreCache := stubStatusCachedLoader(t, func(context.Context, string) (*models.ClusterSnapshot, string, error) {
return nil, "", errors.New("daemon unavailable")
})
defer restoreCache()

stdout, stderr, err := captureProcessOutput(t, func() error {
cmd := doctorCmd()
cmd.SetArgs(nil)
return cmd.Execute()
})
if err != nil {
t.Fatalf("doctor Execute: %v", err)
}
if stderr != "" {
t.Fatalf("expected no stderr, got %q", stderr)
}
stdout = stripANSI(stdout)
if !strings.Contains(stdout, "Core checks passed with advisory warnings") {
t.Fatalf("expected advisory summary, got %q", stdout)
}
if strings.Contains(stdout, "All checks passed") {
t.Fatalf("did not expect full success summary, got %q", stdout)
}
}

func TestDoctorStrictTreatsDaemonFailureAsFailure(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "nodes.yaml")
restorePath := stubDoctorConfigPath(t, func() string {
return tmpFile
})
defer restorePath()

restoreLoad := stubDoctorConfigLoader(t, func(string) (*config.Config, error) {
return &config.Config{
Nodes: []config.NodeConfig{
{Name: "alpha", Hostname: "alpha.local", SSHUser: "axis"},
},
}, nil
})
defer restoreLoad()

restoreSSH := stubDoctorSSHChecker(t, func(context.Context, config.NodeConfig) error {
return nil
})
defer restoreSSH()

restoreCache := stubStatusCachedLoader(t, func(context.Context, string) (*models.ClusterSnapshot, string, error) {
return nil, "", errors.New("daemon unavailable")
})
defer restoreCache()

stdout, stderr, err := captureProcessOutput(t, func() error {
cmd := doctorCmd()
cmd.SetArgs([]string{"--strict"})
return cmd.Execute()
})
if err != nil {
t.Fatalf("doctor Execute: %v", err)
}
if stderr != "" {
t.Fatalf("expected no stderr, got %q", stderr)
}
stdout = stripANSI(stdout)
if !strings.Contains(stdout, "Some checks failed") {
t.Fatalf("expected strict failure summary, got %q", stdout)
}
}

func stubDoctorConfigPath(t *testing.T, fn func() string) func() {
t.Helper()
prev := doctorConfigPath
Expand Down
Loading
Loading