# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## What this is

SDS (Software Deployment Service) is a Spring Boot (Java 21) service that installs, upgrades, and manages NetFoundry Ziti network infrastructure (Edge Routers, Network Controllers, IPsec Tunnelers) on remote hosts. It does this two ways:

- **Ansible**: SDS shells out to `ansible-playbook` directly (see `src/main/java/io/netfoundry/sds/ansible/`, playbooks in `ansible/`). This is the primary/newer path for install and per-host configuration.
- **Salt**: SDS talks to a remote Salt master over HTTP/Ziti (see `src/main/java/io/netfoundry/sds/salt/`, states in `saltstate/`). This is used for orchestration-style jobs (upgrades, staging binaries across a fleet) via `runner_async` jobs that are polled for completion.

The codebase is mid-migration from Salt-orchestrated flows to Ansible-driven flows for some operations (see the `feature/MOP-27320-Update-SDS-to-call-ansible-for-staging-binaries` branch name and the new `EdgeRouterStageSaltRunner`/`EdgeRouterUpgradeStagedSaltRunner`), so expect to find both mechanisms used side by side for the same domain object.

## Build, test, run

- Build: `./mvnw clean install`
- Run all tests: `./mvnw test` (Surefire only runs classes matching `**/*Test.java`)
- Run a single test class: `./mvnw test -Dtest=EdgeRouterDeploymentControllerTest`
- Run a single test method: `./mvnw test -Dtest=EdgeRouterDeploymentControllerTest#methodName`
- Run locally: `./run.sh` — sets up AWS creds/region, `SPRING_PROFILES_ACTIVE=development`, `ANSIBLE_WORK_DIR`, fetches a secret from Secrets Manager, then does `mvnw spring-boot:run`. Requires a JDK 21 (`JAVA_HOME`) and AWS credentials.
- OWASP dependency check: `./mvnw -P dependency_check verify` (suppressions in `owasp/suppressed.xml`)
- Sonar: `./sonar.sh`
- Lint (pre-commit, covers `ansible/`, `saltstate/`, and YAML generally): `pre-commit run --all-files` — runs `ansible-lint` (`ansible/.ansible-lint.yaml`), `salt-lint` (`saltstate/.salt-lint.yaml`), and `yamllint` (`.yamllint.yaml`).

## Architecture

**Request flow**: `controller/*DeploymentController` (REST, e.g. `/deployments/edge-routers/...`) → `AuthUtilService` permission check → `domain/<component>/*Service` (business logic) → either an `ansible/*AnsibleWrapper` (shells to `ansible-playbook`) or `salt/SaltService` (calls the Salt master) → `deployments/JobPollingService` polls async Salt jobs to completion → mapped to a `deployments/DeploymentState` and returned.

Domain objects (`Network`, `NetworkController`, `Host`, `EdgeRouter`, `IpsecTunneler`, etc., under `service/core/domain/`) are passed into every request as full objects from the caller (MOP), not looked up locally — SDS is stateless and has no database of its own.

**Ansible path** (`src/main/java/io/netfoundry/sds/ansible/`):
- `AbstractAnsibleWrapper` is the base class all `*AnsibleWrapper`s extend. It builds the `ansible-playbook` command line, writes a per-run inventory file and a JSON `--extra-vars` file to `TMPDIR`, runs the process with a configurable timeout (`ansible.timeoutDuration`, default 30m), and on non-zero exit dumps the extra-vars and play recap to the application log (via `dumpAnsiblePlayRecapToLog`) — check `JsonOutputObfuscator` for how secrets are scrubbed before logging.
- `getCommonVars(...)` assembles the extra-vars shared by every playbook invocation (network id, SDS password from `SecretManager`, product version, HA flags, FIPS flags via `putFipsVars`, etc). Component-specific wrappers (e.g. `EdgeRouterAnsibleWrapper`) add their own vars and call `runPlaybook(playbookName, ...)`.
- Playbooks live in `ansible/*.yml` with roles in `ansible/roles/`; `ansible/requirements.yaml` and `ansible/sharedVars.yml` are shared inputs.
- `ArgumentValidator` is used to validate/sanitize every value (IPs, domain names, URLs, JWTs) before it's interpolated into extra-vars or the inventory file — always validate new externally-sourced values the same way rather than passing them through raw, since these values end up in shell-adjacent files.

**Salt path** (`src/main/java/io/netfoundry/sds/salt/`):
- `SaltService` is the single entry point; it obtains a per-request `SaltClient` from `SaltClient.Factory` (keyed by network/controller/host) and dispatches to it: `add`/`remove` (apply `SaltState`), `executeRunner` (async orchestration job via `SaltRunner`), `executeHook`, `executeFunction`, `listMinions`.
- `SaltRunner`/`SaltState`/`SaltFunction`/`SaltHook` (in `salt/domain/runner`, `salt/domain/state`, `salt/domain/function`, `salt/domain/hook`) are the four kinds of Salt operations; each maps to a `.sls` state under `saltstate/` or a runner module. A `SaltRunner` implementation just declares `getFunction()`/`getMods()`/`getArgs()` — the pillar data passed via `getArgs()` is what the corresponding Salt orchestration file consumes.
- Salt jobs are async: `executeRunner`/`add`/`remove` return a `SaltJob` with a job id immediately, and the caller must poll for the result. `deployments/JobPollingService.await(...)` does this — first polling `hasJobResult` until true (retry template, `notRetryOn(SaltTerminalFailureException.class)`), then fetching the actual result with a second retry template. `SaltResultTransformer` implementations (e.g. `StateResult.OrchestrationStateResultTransformer`) shape the raw Salt response into a typed result, which `StateResultMapper` then turns into a `DeploymentState`.
- `sds.salt.http-client` toggles between the legacy reactive `WebClient` and the newer blocking `RestClient` for talking to Salt — both implementations exist during this migration; don't remove the `WebClient` path without checking this flag's usage first.

**Domain services** (`domain/<component>/*Service.java`, e.g. `EdgeRouterService`): one per manageable component (edge router, network controller, ipsec tunneler, host, cron, backup, network). These orchestrate the ansible/salt calls for install/upgrade/stage/restart operations and are the layer to modify when adding a new deployment operation. Note the `SyncAllSaltRunner` call before most Salt orchestration jobs — Salt modules must be synced before a new/updated orchestration module can run.

**Auth**: every controller method calls into `AuthUtilService` (`config/AuthUtilService.java`) with an explicit `validate*` call naming the exact operation (e.g. `validateCreateEdgeRouterUpgrade`) before doing any work — follow this pattern (one explicit validate call per HTTP verb/operation) when adding new endpoints rather than a single generic check.

**Config profiles**: `application.properties` + per-profile overrides (`application-development.properties`, `-sandbox`, `-staging`, `-production`). `AbstractAnsibleWrapper.getEnvironment()` maps the active Spring profile to the `app_environment` extra-var passed to every playbook.

## Testing conventions

- Controller/integration-style tests extend `BaseServiceTest`, which mocks out the external boundaries: `SaltClient.Factory`, `SecretManager`, `AuthUtilService`, and the network-versions `WebClient.Builder`. Extend this rather than re-mocking those beans per test.
- Tests use `@ActiveProfiles("test")`; look at `config/SaltTestConfiguration`, `config/ServerTestConfiguration`, `config/SecretManagerConfiguration`, and `config/AuthorizationTestConfiguration` for the test-only bean wiring before adding new ones.
- REST Docs / asciidoc snippets are generated under `src/test/resources/org.springframework.restdocs.templates` — `ApiTest` is the pattern to follow for documenting a new endpoint.
