diff --git a/.gitea/workflows/cd-develop.yml b/.gitea/workflows/cd-develop.yml new file mode 100644 index 0000000..6502dbc --- /dev/null +++ b/.gitea/workflows/cd-develop.yml @@ -0,0 +1,117 @@ +name: CD - Develop (guide → bcos.dev) + +# Guide-specific CI (issue #5). The guide bundles markdown INTO the backend jar at build time, +# so a running instance must be rebuilt + redeployed to pick up content changes — a git-pull is +# not enough. This automates that on every push to develop: build → push :dev → deploy to bcos.dev. +# +# Auto-deploy is intentional and scoped to the DEV environment only (docs, low blast radius). +# Audience filtering is runtime (GUIDE_SERVED_AUDIENCES). The app default is `customer` (fail-safe); +# bcos.dev opts UP to all four tiers explicitly in its compose. Production leaves it unset and so +# serves customer only — internal/dev/bcos content never leaves dev, even if a config line is missed. + +# Auto-deploy live: BCOS_DEV_DEPLOY_KEY provisioned (scoped forced-command key on .251). + +on: + push: + branches: + - develop + paths: + - 'backend/**' + - '.gitea/workflows/cd-develop.yml' + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + env: + GIT_SSL_NO_VERIFY: "true" + MAVEN_OPTS: "-Djava.net.preferIPv4Stack=true -Dmaven.wagon.http.ssl.insecure=true -Dmaven.wagon.http.ssl.allowall=true" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure Maven settings (Gitea package registry) + run: | + mkdir -p ~/.m2 + cat > ~/.m2/settings.xml << 'EOF' + + + giteahiveiq${{ secrets.MAVEN_TOKEN }} + + + + gitea-registry + + + gitea + https://hiveiq-gitea.directlx.dev/api/packages/hiveops/maven + truefalse + + + + + gitea-registry + + EOF + + # mvn package FIRST — the Dockerfile does `COPY target/*.jar`, so skipping this ships stale + # content (the exact trap that makes the manual process error-prone). + - name: Build backend JAR (bakes in content) + run: cd backend && mvn clean package -DskipTests -B + + - name: Build + push image + run: | + echo "${{ secrets.DEV_REGISTRY_PASSWORD }}" | docker login ${{ secrets.DEV_REGISTRY_URL }} -u ${{ secrets.DEV_REGISTRY_USERNAME }} --password-stdin + docker build -t ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev backend/ + docker push ${{ secrets.DEV_REGISTRY_URL }}/hiveiq-guide-backend:dev + + # DEPENDENCY (issue #5, coordinate with CI/runner session): needs BCOS_DEV_DEPLOY_KEY secret + # (SSH key for bcosadmin@.251) + runner network reachability to 173.231.195.251. + - name: Deploy to bcos.dev + run: | + mkdir -p ~/.ssh && echo "${{ secrets.BCOS_DEV_DEPLOY_KEY }}" > ~/.ssh/dk && chmod 600 ~/.ssh/dk + ssh -i ~/.ssh/dk -o StrictHostKeyChecking=no bcosadmin@173.231.195.251 \ + 'cd ~/hiveiq/hiveops-openmetal/hiveops/instances/dev && docker compose pull hiveiq-guide-backend && docker compose up -d hiveiq-guide-backend' + + - name: Smoke — guide backend healthy + content served + run: | + # `compose up -d` restarts the Spring Boot backend, which needs well over 60s to warm + # up and report healthy — poll for ~5 min so a normal restart isn't a false failure. + for i in $(seq 1 30); do + s=$(curl -sk -o /dev/null -w '%{http_code}' https://api.bcos.dev/guide/actuator/health || echo 000) + [ "$s" = "200" ] && { echo "guide healthy (attempt $i)"; exit 0; } + echo "attempt $i/30: health=$s — waiting 10s" + sleep 10 + done + echo "::error::guide did not report healthy 5 min after deploy"; exit 1 + + # Posts a ✅/🔴 card to #hiveops-dev-deploy on every run (deploy result). Runs even on + # failure so a broken build/deploy/smoke is announced, not silent. SLACK_WEBHOOK_URL is an + # org secret; if unset the step no-ops so forks/dry-runs don't error. + - name: Notify Slack (#hiveops-dev-deploy) + if: always() + continue-on-error: true # a notification must NEVER fail the deploy + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + JOB_STATUS: ${{ job.status }} + ACTOR: ${{ github.actor }} + REF_NAME: ${{ github.ref_name }} + SHA: ${{ github.sha }} + RUN_NUMBER: ${{ github.run_number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + run: | + [ -z "$SLACK_WEBHOOK_URL" ] && { echo "no SLACK_WEBHOOK_URL secret — skipping"; exit 0; } + SHORT="${SHA:0:8}" + FIRST="${COMMIT_MSG%%$'\n'*}" # first line via param-expansion (no pipe) + SUBJECT="$(printf '%s' "$FIRST" | tr -d '"\\' | cut -c1-100)" + if [ "$JOB_STATUS" = "success" ]; then + COLOR="#2eb67d"; TITLE=":white_check_mark: guide → bcos.dev deployed" + else + COLOR="#e01e5a"; TITLE=":red_circle: guide → bcos.dev deploy FAILED ($JOB_STATUS)" + fi + PAYLOAD=$(printf '{"attachments":[{"color":"%s","blocks":[{"type":"section","text":{"type":"mrkdwn","text":"*%s*"}},{"type":"section","fields":[{"type":"mrkdwn","text":"*Commit*\\n`%s` %s"},{"type":"mrkdwn","text":"*Branch*\\n%s"},{"type":"mrkdwn","text":"*By*\\n%s"},{"type":"mrkdwn","text":"*Run*\\n#%s"}]},{"type":"actions","elements":[{"type":"button","text":{"type":"plain_text","text":"View run"},"url":"%s"}]}]}]}' \ + "$COLOR" "$TITLE" "$SHORT" "$SUBJECT" "$REF_NAME" "$ACTOR" "$RUN_NUMBER" "$RUN_URL") + # Non-fatal: capture the HTTP code, never let curl's exit fail the step. + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 -X POST -H 'Content-type: application/json' --data "$PAYLOAD" "$SLACK_WEBHOOK_URL" || echo "curl-error") + echo "slack notify HTTP: $code (200 = card posted; anything else = runner could not reach Slack)" diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..f6a79b4 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,48 @@ +# hiveops-guide — Design (approved 2026-06-30) + +One knowledge base that (a) teaches customers how each module works and (b) is the +source-of-truth for module behavior (for admins + Claude). Markdown-authored, served by the +backend API, read in a tabbed slide-out. + +## Decisions (Johannes, 2026-06-30) +1. Content = **markdown files in git**, still served via **backend API calls** (not bundled in + the frontend). +2. **Start over** — greenfield. No backward-compat with the old `content_json` guides or the + `/api/v1/guides/{pageKey}` contract; the Fleet "Guide" buttons get re-wired to the new API. +3. **Retire the Guide Admin CRUD app.** The guide keeps **internal docs** (technical guides for + admins/Claude, `audience: internal`). + +## Content model — markdown in the repo +`content///*.md` — folder per module, **one file per tab**. Frontmatter: +```yaml +--- +module: devices.sw-deploy +title: Software Deployment +tab: The Data # the tab label in the slide-out +order: 20 # tab order +audience: customer # or "internal" (hidden from customers) +--- + +``` +Cross-cutting technical guides: `content/technical/*.md` (e.g. fleet→Kafka→devices, service map). + +## Backend (Spring Boot service kept; model replaced) +- Scans `content/**/*.md` at startup (cached), parses frontmatter + body. +- API (JWT-gated): + - `GET /api/v1/guide/nav?audience=` → apps → modules → tabs tree + - `GET /api/v1/guide/modules/{module}?audience=` → module + its tabs (markdown bodies) +- Remove the DB `content_json` model + admin CRUD endpoints. `audience` gates customer vs internal. + +## Frontend +- New **tabbed slide-out reader** (markdown rendered with `marked`); nav + tabs from the API. +- Retire the Guide Admin CRUD app. + +## Consumers +- Re-wire the Fleet "Guide" buttons (and future in-app guide entry points) to the new API. + +## Phases +1. **Backend** — markdown content model + `nav`/`modules` API; retire DB/CRUD. +2. **Frontend** — tabbed slide-out reader. +3. **Content** — author `devices.sw-deploy` + `fleet.tasks` as the pattern (Overview / Data / + How-to + an internal Technical tab), then fan out to all modules. +4. **Technical guides** (internal) — fleet→Kafka→devices, service map, etc. diff --git a/backend/pom.xml b/backend/pom.xml index d3bd5f2..d082c04 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -14,7 +14,7 @@ com.hiveops hiveops-bom - 1.0.2 + 1.0.7 @@ -29,10 +29,6 @@ org.springframework.boot spring-boot-starter-web - - org.springframework.boot - spring-boot-starter-data-jpa - org.springframework.boot spring-boot-starter-security @@ -46,21 +42,6 @@ spring-boot-starter-actuator - - org.postgresql - postgresql - runtime - - - - org.flywaydb - flyway-core - - - org.flywaydb - flyway-database-postgresql - - com.hiveops hiveops-security-common diff --git a/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java index 0828f67..0b95c3e 100644 --- a/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java +++ b/backend/src/main/java/com/hiveops/guide/config/SecurityConfig.java @@ -35,11 +35,9 @@ public class SecurityConfig { .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))) .authorizeHttpRequests(auth -> auth .requestMatchers("/actuator/health", "/error").permitAll() - // Consumer: any authenticated customer, user, or admin - .requestMatchers(HttpMethod.GET, "/api/v1/guides/**") - .hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "QDS_ADMIN", "BCOS_ADMIN") - // Admin CRUD — handled by @PreAuthorize on the controller - .requestMatchers("/api/v1/admin/guides/**").authenticated() + // Read-only guide API: any authenticated user (audience gating done in the controller) + .requestMatchers(HttpMethod.GET, "/api/v1/guide/**") + .hasAnyRole("USER", "CUSTOMER", "ADMIN", "MSP_ADMIN", "BCOS_ADMIN") .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java new file mode 100644 index 0000000..1a38b14 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/controller/FeedbackController.java @@ -0,0 +1,34 @@ +package com.hiveops.guide.controller; + +import com.hiveops.guide.service.GiteaFeedbackService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** In-guide "report a bug / suggest an enhancement" → creates a Gitea issue. Requires a logged-in user. */ +@RestController +@RequestMapping("/api/v1/guide/feedback") +@RequiredArgsConstructor +public class FeedbackController { + + private final GiteaFeedbackService service; + + public record FeedbackRequest(String type, String area, String title, String description, String module) {} + + @PostMapping + public ResponseEntity submit(@RequestBody FeedbackRequest r, Authentication auth) { + String email = auth != null ? auth.getName() : null; + String role = auth != null + ? auth.getAuthorities().stream().findFirst() + .map(a -> a.getAuthority().replaceFirst("^ROLE_", "")).orElse(null) + : null; + GiteaFeedbackService.Result res = service.create( + r.type(), r.area(), r.title(), r.description(), r.module(), email, role); + return res.success() + ? ResponseEntity.ok(Map.of("number", res.number(), "url", res.url())) + : ResponseEntity.badRequest().body(Map.of("error", res.error() == null ? "Failed" : res.error())); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/controller/GuideController.java b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java index 747e27b..be29be4 100644 --- a/backend/src/main/java/com/hiveops/guide/controller/GuideController.java +++ b/backend/src/main/java/com/hiveops/guide/controller/GuideController.java @@ -1,86 +1,50 @@ package com.hiveops.guide.controller; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.hiveops.guide.entity.Guide; -import com.hiveops.guide.service.GuideService; +import com.hiveops.guide.dto.GuideDtos.ModuleView; +import com.hiveops.guide.dto.GuideDtos.NavApp; +import com.hiveops.guide.service.MarkdownGuideService; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.Map; -import java.util.Optional; +/** + * Read-only guide API backed by markdown files. Internal-audience content is only + * returned to admin roles (MSP_ADMIN / BCOS_ADMIN); everyone else sees customer content. + */ @RestController +@RequestMapping("/api/v1/guide") @RequiredArgsConstructor -@Slf4j public class GuideController { - private final GuideService guideService; - private final ObjectMapper objectMapper; + private final MarkdownGuideService service; - // ── Consumer: any authenticated user (called by SPAs via GuidePanel) ───── - - @GetMapping("/api/v1/guides/{pageKey}") - public ResponseEntity getGuide(@PathVariable String pageKey) { - Optional found = guideService.findByPageKey(pageKey); - if (found.isEmpty()) return ResponseEntity.notFound().build(); - try { - Object content = objectMapper.readValue(found.get().getContentJson(), Object.class); - return ResponseEntity.ok(content); - } catch (JsonProcessingException e) { - log.error("Failed to parse guide content for pageKey: {}", pageKey, e); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); - } + @GetMapping("/nav") + public List nav(Authentication auth) { + return service.nav(isInternal(auth), isBcos(auth)); } - // ── Admin: list + CRUD (MSP_ADMIN or BCOS_ADMIN) ───────────────────────── - - @GetMapping("/api/v1/admin/guides") - @PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") - public ResponseEntity> listGuides() { - return ResponseEntity.ok(guideService.findAll()); + @GetMapping("/modules/{module}") + public ResponseEntity module(@PathVariable String module, Authentication auth) { + return service.module(module, isInternal(auth), isBcos(auth)) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); } - @PostMapping("/api/v1/admin/guides") - @PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") - public ResponseEntity createGuide(@RequestBody Map body, Authentication auth) { - String pageKey = requireString(body, "pageKey"); - String contentJson = requireString(body, "contentJson"); - Boolean enabled = body.get("enabled") instanceof Boolean b ? b : Boolean.TRUE; - Guide created = guideService.create(pageKey, contentJson, enabled, auth != null ? auth.getName() : "system"); - return ResponseEntity.status(HttpStatus.CREATED).body(created); + private boolean isInternal(Authentication auth) { + if (auth == null) return false; + return auth.getAuthorities().stream() + .map(a -> a.getAuthority()) + .anyMatch(a -> a.equals("ROLE_MSP_ADMIN") || a.equals("ROLE_BCOS_ADMIN")); } - @PutMapping("/api/v1/admin/guides/{pageKey}") - @PreAuthorize("hasAnyRole('MSP_ADMIN','BCOS_ADMIN')") - public ResponseEntity updateGuide(@PathVariable String pageKey, - @RequestBody Map body, - Authentication auth) { - String contentJson = requireString(body, "contentJson"); - Boolean enabled = body.get("enabled") instanceof Boolean b ? b : null; - Guide updated = guideService.update(pageKey, contentJson, enabled, auth != null ? auth.getName() : "system"); - return ResponseEntity.ok(updated); - } - - @DeleteMapping("/api/v1/admin/guides/{pageKey}") - @PreAuthorize("hasRole('BCOS_ADMIN')") - public ResponseEntity deleteGuide(@PathVariable String pageKey) { - guideService.delete(pageKey); - return ResponseEntity.noContent().build(); - } - - private String requireString(Map body, String key) { - Object val = body.get(key); - if (!(val instanceof String s) || s.isBlank()) { - throw new org.springframework.web.server.ResponseStatusException( - HttpStatus.BAD_REQUEST, key + " is required"); - } - return s; + /** BCOS_ADMIN only — gates the strictest tier (audience: bcos), e.g. the Processes section. */ + private boolean isBcos(Authentication auth) { + if (auth == null) return false; + return auth.getAuthorities().stream() + .map(a -> a.getAuthority()) + .anyMatch(a -> a.equals("ROLE_BCOS_ADMIN")); } } diff --git a/backend/src/main/java/com/hiveops/guide/dto/GuideDtos.java b/backend/src/main/java/com/hiveops/guide/dto/GuideDtos.java new file mode 100644 index 0000000..08b0fbe --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/dto/GuideDtos.java @@ -0,0 +1,18 @@ +package com.hiveops.guide.dto; + +import java.util.List; + +/** API response shapes for the guide. */ +public final class GuideDtos { + + /** nav: apps → modules → tabs (metadata only, no bodies). */ + public record NavTab(String tab, int order, String audience) {} + public record NavModule(String module, String title, List tabs) {} + public record NavApp(String app, List modules) {} + + /** module view: one module with its tabs, each carrying the markdown body. */ + public record ModuleTab(String tab, int order, String audience, String body) {} + public record ModuleView(String module, String title, List tabs) {} + + private GuideDtos() {} +} diff --git a/backend/src/main/java/com/hiveops/guide/entity/Guide.java b/backend/src/main/java/com/hiveops/guide/entity/Guide.java deleted file mode 100644 index 7cb3f7c..0000000 --- a/backend/src/main/java/com/hiveops/guide/entity/Guide.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.hiveops.guide.entity; - -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.CreationTimestamp; -import org.hibernate.annotations.UpdateTimestamp; - -import java.time.Instant; - -@Entity -@Table(name = "guides") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class Guide { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "page_key", unique = true, nullable = false, length = 100) - private String pageKey; - - @Column(name = "content_json", nullable = false, columnDefinition = "TEXT") - private String contentJson; - - @Column(nullable = false) - @Builder.Default - private Boolean enabled = true; - - @Column(name = "updated_by", length = 255) - private String updatedBy; - - @CreationTimestamp - @Column(name = "created_at", nullable = false, updatable = false) - private Instant createdAt; - - @UpdateTimestamp - @Column(name = "updated_at") - private Instant updatedAt; -} diff --git a/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java index 85b546a..fd6707b 100644 --- a/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/com/hiveops/guide/exception/GlobalExceptionHandler.java @@ -2,7 +2,6 @@ package com.hiveops.guide.exception; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -29,13 +28,6 @@ public class GlobalExceptionHandler { .body(Map.of("error", ex.getMessage() != null ? ex.getMessage() : "Invalid request")); } - @ExceptionHandler(DataIntegrityViolationException.class) - public ResponseEntity> handleDataIntegrity(DataIntegrityViolationException ex) { - logger.error("Data constraint violation: {}", ex.getMessage()); - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(Map.of("error", "A guide with this page key already exists.")); - } - @ExceptionHandler(Exception.class) public ResponseEntity> handleAll(Exception ex) { logger.error("Unhandled error: {}", ex.getMessage(), ex); diff --git a/backend/src/main/java/com/hiveops/guide/model/GuideDoc.java b/backend/src/main/java/com/hiveops/guide/model/GuideDoc.java new file mode 100644 index 0000000..e8ccb1a --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/model/GuideDoc.java @@ -0,0 +1,15 @@ +package com.hiveops.guide.model; + +/** + * One parsed guide markdown file = one tab of a module. + * Source of truth is the frontmatter; folder layout is just organisation. + */ +public record GuideDoc( + String app, // derived from module prefix, e.g. "devices" + String module, // full module key, e.g. "devices.sw-deploy" + String title, // module title (shared across its tabs) + String tab, // tab label, e.g. "Overview" + int order, // tab order within the module + String audience, // "customer" | "internal" + String body // raw markdown (rendered client-side) +) {} diff --git a/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java b/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java deleted file mode 100644 index 74d911c..0000000 --- a/backend/src/main/java/com/hiveops/guide/repository/GuideRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.hiveops.guide.repository; - -import com.hiveops.guide.entity.Guide; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -import java.util.List; -import java.util.Optional; - -@Repository -public interface GuideRepository extends JpaRepository { - Optional findByPageKeyAndEnabledTrue(String pageKey); - Optional findByPageKey(String pageKey); - List findAllByOrderByPageKeyAsc(); - boolean existsByPageKey(String pageKey); -} diff --git a/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java b/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java new file mode 100644 index 0000000..5b20bea --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/service/GiteaFeedbackService.java @@ -0,0 +1,116 @@ +package com.hiveops.guide.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; + +/** + * Creates Gitea Bug/Enhancement issues from in-guide feedback. Mirrors the hiveops-browser + * feedback flow but keeps the token server-side. Reaches Gitea over the public (Cloudflare) route. + */ +@Service +@Slf4j +public class GiteaFeedbackService { + + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + + @Value("${gitea.base-url:https://hiveiq-gitea.directlx.dev}") + private String baseUrl; + @Value("${gitea.feedback.token:}") + private String token; + + private static final String ORG = "hiveiq-src"; + + /** area key (= guide module app-prefix, or explicit) → repo name. Default: the guide itself. */ + private static final Map REPOS = Map.ofEntries( + Map.entry("fleet", "hiveops-fleet"), Map.entry("devices", "hiveops-devices"), + Map.entry("incident", "hiveops-incident"), Map.entry("transactions", "hiveops-transactions"), + Map.entry("analytics", "hiveops-analytics"), Map.entry("reports", "hiveops-reports"), + Map.entry("messaging", "hiveops-messaging"), Map.entry("claims", "hiveops-claims"), + Map.entry("recon", "hiveops-recon"), Map.entry("vault", "hiveops-vault"), + Map.entry("aria", "hiveops-aria"), Map.entry("msp", "hiveops-msp"), + Map.entry("dashboard", "hiveops-dashboard"), Map.entry("profile", "hiveops-profile"), + Map.entry("mobile", "hiveops-mobile"), Map.entry("agent", "hiveops-agent"), + Map.entry("guide", "hiveops-guide")); + + public record Result(boolean success, Integer number, String url, String error) {} + + public Result create(String type, String area, String title, String description, + String module, String email, String role) { + if (token == null || token.isBlank()) + return new Result(false, null, null, "Feedback is not configured."); + if (title == null || title.isBlank() || description == null || description.isBlank()) + return new Result(false, null, null, "Title and description are required."); + + String labelName = "enhancement".equalsIgnoreCase(type) ? "Enhancement" : "Bug"; + String repoName = REPOS.getOrDefault(area == null ? "" : area.toLowerCase(), "hiveops-guide"); + String repo = ORG + "/" + repoName; + + try { + Integer labelId = findLabel(repo, labelName); + if (labelId == null) labelId = createLabel(repo, labelName); + + String body = description + "\n\n---\n" + + "**Submitted via:** guide.bcos.dev\n" + + "**Area:** " + repoName + (module != null && !module.isBlank() ? " · " + module : "") + "\n" + + "**Reporter:** " + (email == null ? "unknown" : email) + + (role != null ? " (" + role + ")" : "") + "\n"; + + ObjectNode payload = mapper.createObjectNode(); + payload.put("title", title.trim()); + payload.put("body", body); + if (labelId != null) payload.putArray("labels").add(labelId.intValue()); + + HttpResponse resp = http.send( + req(repo, "/issues").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload))).build(), + HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() >= 200 && resp.statusCode() < 300) { + JsonNode n = mapper.readTree(resp.body()); + log.info("Feedback issue created: {} #{}", repo, n.path("number").asInt()); + return new Result(true, n.path("number").asInt(), n.path("html_url").asText(), null); + } + log.warn("Gitea issue create failed {}: {}", resp.statusCode(), resp.body()); + return new Result(false, null, null, "Gitea returned " + resp.statusCode()); + } catch (Exception e) { + log.warn("Feedback submit failed: {}", e.getMessage()); + return new Result(false, null, null, e.getMessage()); + } + } + + private Integer findLabel(String repo, String name) throws Exception { + HttpResponse r = http.send(req(repo, "/labels?limit=100").GET().build(), + HttpResponse.BodyHandlers.ofString()); + if (r.statusCode() != 200) return null; + for (JsonNode l : mapper.readTree(r.body())) + if (l.path("name").asText().equalsIgnoreCase(name)) return l.path("id").asInt(); + return null; + } + + private Integer createLabel(String repo, String name) throws Exception { + ObjectNode p = mapper.createObjectNode(); + p.put("name", name); + p.put("color", name.equalsIgnoreCase("Enhancement") ? "#a2eeef" : "#d73a4a"); + HttpResponse r = http.send( + req(repo, "/labels").POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(p))).build(), + HttpResponse.BodyHandlers.ofString()); + return (r.statusCode() >= 200 && r.statusCode() < 300) ? mapper.readTree(r.body()).path("id").asInt() : null; + } + + private HttpRequest.Builder req(String repo, String path) { + return HttpRequest.newBuilder(URI.create(baseUrl + "/api/v1/repos/" + repo + path)) + .header("Authorization", "token " + token) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(20)); + } +} diff --git a/backend/src/main/java/com/hiveops/guide/service/GuideService.java b/backend/src/main/java/com/hiveops/guide/service/GuideService.java deleted file mode 100644 index 540a4f9..0000000 --- a/backend/src/main/java/com/hiveops/guide/service/GuideService.java +++ /dev/null @@ -1,75 +0,0 @@ -package com.hiveops.guide.service; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.hiveops.guide.entity.Guide; -import com.hiveops.guide.repository.GuideRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -import java.util.List; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -@Slf4j -public class GuideService { - - private final GuideRepository guideRepository; - private final ObjectMapper objectMapper; - - @Transactional(readOnly = true) - public Optional findByPageKey(String pageKey) { - return guideRepository.findByPageKeyAndEnabledTrue(pageKey); - } - - @Transactional(readOnly = true) - public List findAll() { - return guideRepository.findAllByOrderByPageKeyAsc(); - } - - @Transactional - public Guide create(String pageKey, String contentJson, Boolean enabled, String updatedBy) { - if (guideRepository.existsByPageKey(pageKey)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Guide already exists for pageKey: " + pageKey); - } - validateJson(contentJson); - Guide guide = Guide.builder() - .pageKey(pageKey) - .contentJson(contentJson) - .enabled(enabled != null ? enabled : Boolean.TRUE) - .updatedBy(updatedBy) - .build(); - return guideRepository.save(guide); - } - - @Transactional - public Guide update(String pageKey, String contentJson, Boolean enabled, String updatedBy) { - Guide guide = guideRepository.findByPageKey(pageKey) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Guide not found: " + pageKey)); - validateJson(contentJson); - guide.setContentJson(contentJson); - if (enabled != null) guide.setEnabled(enabled); - guide.setUpdatedBy(updatedBy); - return guideRepository.save(guide); - } - - @Transactional - public void delete(String pageKey) { - Guide guide = guideRepository.findByPageKey(pageKey) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Guide not found: " + pageKey)); - guideRepository.delete(guide); - } - - private void validateJson(String json) { - try { - objectMapper.readTree(json); - } catch (JsonProcessingException e) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "contentJson is not valid JSON: " + e.getOriginalMessage()); - } - } -} diff --git a/backend/src/main/java/com/hiveops/guide/service/MarkdownGuideService.java b/backend/src/main/java/com/hiveops/guide/service/MarkdownGuideService.java new file mode 100644 index 0000000..95a9d99 --- /dev/null +++ b/backend/src/main/java/com/hiveops/guide/service/MarkdownGuideService.java @@ -0,0 +1,151 @@ +package com.hiveops.guide.service; + +import com.hiveops.guide.dto.GuideDtos.*; +import com.hiveops.guide.model.GuideDoc; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Loads guide content from markdown files on the classpath ({@code content/**\/*.md}) at startup, + * parses YAML-ish frontmatter, and serves the nav tree + per-module views. No database. + */ +@Service +@Slf4j +public class MarkdownGuideService { + + private volatile List docs = List.of(); + + /** Which audience tiers this instance serves — the per-environment cap. + * bcos.dev/DLX = all; production = customer,internal. */ + @org.springframework.beans.factory.annotation.Value("${guide.served-audiences:customer,internal,dev,bcos}") + private String servedRaw; + private volatile Set served; + + private Set served() { + Set s = served; + if (s == null) { + s = new HashSet<>(); + for (String x : servedRaw.split(",")) { + x = x.trim().toLowerCase(Locale.ROOT); + if (!x.isEmpty()) s.add(x); + } + served = s; + log.info("Guide serving audience tiers: {}", s); + } + return s; + } + + @PostConstruct + public void load() { + List loaded = new ArrayList<>(); + try { + var resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources("classpath*:content/**/*.md"); + for (Resource r : resources) { + try (var in = r.getInputStream()) { + GuideDoc doc = parse(new String(in.readAllBytes(), StandardCharsets.UTF_8)); + if (doc != null) loaded.add(doc); + else log.warn("Guide file missing valid frontmatter, skipped: {}", r.getFilename()); + } catch (Exception e) { + log.warn("Failed to read guide file {}: {}", r.getFilename(), e.getMessage()); + } + } + } catch (IOException e) { + log.error("Failed to scan guide markdown content", e); + } + this.docs = List.copyOf(loaded); + log.info("Loaded {} guide docs across {} modules", docs.size(), + docs.stream().map(GuideDoc::module).distinct().count()); + } + + /** frontmatter between leading '---' fences, then the markdown body. */ + private GuideDoc parse(String raw) { + String content = raw.replace("\r\n", "\n"); + if (!content.startsWith("---\n")) return null; + int end = content.indexOf("\n---", 4); + if (end < 0) return null; + + Map meta = new HashMap<>(); + for (String line : content.substring(4, end).split("\n")) { + int colon = line.indexOf(':'); + if (colon < 0) continue; + String key = line.substring(0, colon).trim(); + String val = line.substring(colon + 1).trim(); + // strip surrounding quotes and trailing "# comment" + int hash = val.indexOf(" #"); + if (hash >= 0) val = val.substring(0, hash).trim(); + if (val.length() >= 2 && val.startsWith("\"") && val.endsWith("\"")) val = val.substring(1, val.length() - 1); + if (!key.isEmpty()) meta.put(key, val); + } + + String module = meta.get("module"); + String tab = meta.get("tab"); + if (module == null || module.isBlank() || tab == null || tab.isBlank()) return null; + + String body = content.substring(end + 4).replaceFirst("^\\n+", "").stripTrailing(); + String app = module.contains(".") ? module.substring(0, module.indexOf('.')) : module; + String title = meta.getOrDefault("title", module); + String audience = meta.getOrDefault("audience", "customer").toLowerCase(Locale.ROOT); + int order = 100; + try { order = Integer.parseInt(meta.getOrDefault("order", "100")); } catch (NumberFormatException ignored) {} + + return new GuideDoc(app, module, title, tab, order, audience, body); + } + + /** Audience ladder: customer < internal < dev < bcos. + * First the environment cap (served tiers), then the caller's role. + * customer→all · internal→MSP or BCOS admin · dev/bcos→BCOS admin only. + * NB: 'dev' is developer/architecture content and is BCOS-only — it must NOT be + * gated at MSP (issue #18). Only 'internal' is shared with MSP admins. */ + private boolean visible(GuideDoc d, boolean includeInternal, boolean includeBcos) { + String a = d.audience(); + if (!served().contains(a)) return false; // environment cap (prod = customer,internal) + return switch (a) { + case "bcos", "dev" -> includeBcos; // BCOS_ADMIN only + case "internal" -> includeInternal; // MSP_ADMIN or BCOS_ADMIN + default -> true; // customer + }; + } + + /** apps → modules → tabs, filtered by audience. */ + public List nav(boolean includeInternal, boolean includeBcos) { + Map>> byApp = new TreeMap<>(); + for (GuideDoc d : docs) { + if (!visible(d, includeInternal, includeBcos)) continue; + byApp.computeIfAbsent(d.app(), k -> new TreeMap<>()) + .computeIfAbsent(d.module(), k -> new ArrayList<>()).add(d); + } + List apps = new ArrayList<>(); + for (var appEntry : byApp.entrySet()) { + List modules = new ArrayList<>(); + for (var modEntry : appEntry.getValue().entrySet()) { + List mdocs = modEntry.getValue(); + mdocs.sort(Comparator.comparingInt(GuideDoc::order)); + List tabs = mdocs.stream() + .map(d -> new NavTab(d.tab(), d.order(), d.audience())).toList(); + modules.add(new NavModule(modEntry.getKey(), mdocs.get(0).title(), tabs)); + } + apps.add(new NavApp(appEntry.getKey(), modules)); + } + return apps; + } + + /** one module with its tabs (markdown bodies), filtered by audience. */ + public Optional module(String moduleKey, boolean includeInternal, boolean includeBcos) { + List mdocs = docs.stream() + .filter(d -> d.module().equals(moduleKey) && visible(d, includeInternal, includeBcos)) + .sorted(Comparator.comparingInt(GuideDoc::order)) + .toList(); + if (mdocs.isEmpty()) return Optional.empty(); + List tabs = mdocs.stream() + .map(d -> new ModuleTab(d.tab(), d.order(), d.audience(), d.body())).toList(); + return Optional.of(new ModuleView(moduleKey, mdocs.get(0).title(), tabs)); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4aa97fa..4a6fb48 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -2,20 +2,7 @@ spring.application.name=hiveops-guide server.port=8099 -spring.datasource.url=${DB_URL:jdbc:postgresql://localhost:5432/hiveiq_guide} -spring.datasource.username=${DB_USERNAME:hiveiq} -spring.datasource.password=${DB_PASSWORD} -spring.datasource.driver-class-name=org.postgresql.Driver - -spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect -spring.jpa.hibernate.ddl-auto=validate - -spring.flyway.enabled=true -spring.flyway.locations=classpath:db/migration -spring.flyway.baseline-on-migrate=true -spring.flyway.baseline-version=1 -spring.flyway.validate-on-migrate=true -spring.jpa.show-sql=false +# Guide content is markdown files on the classpath (content/**/*.md) — no database. logging.level.root=WARN logging.level.com.hiveops.guide=INFO @@ -35,3 +22,20 @@ jwt.secret=${JWT_SECRET} internal.secret=${INTERNAL_SECRET:dev-internal-secret} cors.allowed-origins=${CORS_ALLOWED_ORIGINS:http://localhost:5188} + +# Audience tiers this instance serves (per-environment cap), checked BEFORE any role +# gating — a tier that is not served never leaves the process, whatever the caller's role. +# +# The default is deliberately the SAFEST tier, not the fullest: a missing or misspelled +# GUIDE_SERVED_AUDIENCES must never be the reason internal content reaches a customer. +# MSP_ADMIN is a customer-facing role (QDS log in as MSP_ADMIN), so a fail-open default +# would expose the Architect/Claude tiers to customers on one forgotten env line. +# +# Each environment opts UP explicitly: +# production -> customer (leave unset; the default is correct) +# bcos.dev/DLX -> customer,internal,dev,bcos (set in that instance's compose) +guide.served-audiences=${GUIDE_SERVED_AUDIENCES:customer} + +# In-guide feedback → Gitea issues (server-side token; reaches Gitea via public route) +gitea.base-url=${GITEA_BASE_URL:https://hiveiq-gitea.directlx.dev} +gitea.feedback.token=${GITEA_FEEDBACK_TOKEN:} diff --git a/backend/src/main/resources/content/agent/activeteller/architect.md b/backend/src/main/resources/content/agent/activeteller/architect.md new file mode 100644 index 0000000..a453adb --- /dev/null +++ b/backend/src/main/resources/content/agent/activeteller/architect.md @@ -0,0 +1,115 @@ +--- +module: agent.activeteller +title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-activeteller` source. Verify against code before relying on specifics. + +## Module shape + +Package `com.hiveops.activeteller`, six classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]): + +- **`ActiveTellerModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`), the shared single-thread `ScheduledExecutorService` (daemon thread named `activeteller`), and constructs the three monitors + the emitter. +- **`TellerSessionMonitor`** (package-private) — parses `ActiveTellerAgent_*.log`. +- **`SymXchangeMonitor`** (package-private) — parses `SymXchange{date}.log`. +- **`CheckProcessingMonitor`** (package-private) — parses `CheckProcessingResults{date}.log`. +- **`RollingLogTailer`** (package-private) — shared tail-by-offset reader; each monitor owns one instance. +- **`IatEventEmitter`** (package-private) — thin wrapper over `IncidentEventClient` that builds the `CreateJournalEventRequest` and centralizes send-error handling. + +The three monitors are plain constructed objects, not their own `AgentModule`s — `ActiveTellerModule` drives all three from a single poll loop. + +## Lifecycle & config gating + +`initialize(ModuleContext)` is defensive and never throws for a mis-provisioned box — it just leaves `ready=false`, which `isEnabled()` returns. It bails (module stays disabled) when any of these fail, each with a distinct log line: + +1. `device.type != NH_IAT` +2. `activeteller.log.dir` unset +3. `activeteller.log.dir` is not an existing directory +4. neither `agent.endpoint` nor `incident.endpoint` is configured + +Endpoint prefix is chosen the standard way: `prefix = agentEndpoint != null ? "agent" : "incident"`, then `HttpClientSettings settings = HttpClientHelper.loadSettingsFromProperties(props, prefix)`. The `IncidentEventClient` is built once with `(settings, atmName, country)` from `ModuleContext`. `isHealthy()` = scheduler alive and not terminated. + +## Flow: poll → tail → detect → emit + +``` +ActiveTellerModule.start() + → ScheduledExecutorService (daemon "activeteller") + ├─ scheduleAtFixedRate(poll, 0, pollSec, SECONDS) // default 30s + └─ scheduleAtFixedRate(heartbeat, heartbeatSec, heartbeatSec, SECONDS) // default 900s + poll(): + tellerMonitor.poll(); symXchangeMonitor.poll(); checkMonitor.poll(); + heartbeat(): + if any monitor isAlive() → emit IAT_HEARTBEAT with concatenated fragments +``` + +`poll()` wraps all three in a try/catch so one monitor's parse error can't kill the scheduler. `heartbeat()` skips entirely if no monitor has seen a line today (`isAlive()` = `tailer.currentFile() != null`), so idle machines stay quiet. + +### `RollingLogTailer` — the shared tail engine + +Every monitor reads through one `RollingLogTailer` constructed with `(baseLogDir, tag, FileResolver)`. On each `poll(Consumer)`: + +- Computes `dailyFolder = baseLogDir.resolve(yyyyMMdd)`; if it isn't a directory, no-ops. +- Calls the `FileResolver` to pick the concrete file inside that folder: + - `fixedName(prefix, suffix)` → `{folder}/{prefix}{yyyyMMdd}{suffix}` (SymXchange, CheckProcessingResults). + - `newestMatching(prefix, suffix)` → the most-recently-modified file matching prefix+suffix (session-stamped `ActiveTellerAgent_*.log`). +- Tail-by-offset: opens with `RandomAccessFile`, `seek(fileOffset)`, reads new lines to EOF, advances `fileOffset = raf.getFilePointer()`. +- **Rotation/new file:** when the resolved path changes (new day or new session file), logs `watching `, sets `currentFile` and resets `fileOffset = 0` (reads the new file from the top). +- **Truncation guard:** if `raf.length() < fileOffset`, resets offset to 0. +- **Encoding fix:** `RandomAccessFile.readLine()` decodes as ISO-8859-1; each line is re-decoded to UTF-8 (`new String(raw.getBytes(ISO_8859_1), UTF_8)`) before the consumer sees it. + +`RollingLogTailer` holds **no** detection logic — the monitors own all parsing. + +### `TellerSessionMonitor` — session state machine + +Line format: `yyyy-MM-dd HH:mm:ss `. Maintains single-customer session state (an IAT serves one customer at a time): `requestAt`, `sessionAt`, `videoUp`, `currentTeller`, plus a `logClock` (timestamp of the last processed line). + +- `POST TellerSessionRequest message received` → `requestAt = ts` (idle `DELETE` heartbeats are ignored). +- A payload containing `"TellerSessionRequestId"` → session assigned (extracts `"TellerName"`; dedups repeated echoes of the same payload); clears `requestAt`, resets video flags. +- `OnAdd for RemoteControlSession` (while a session is active) → `videoUp = true`. +- `Ending teller session` / `OnDelete for TellerSessionRequest` → session end; if video never came up, emit `IAT_VIDEO_SESSION_FAIL`. +- REST outcome `(https?://…) server response (\w+)` → success statuses `{OK, Created, Accepted, NoContent, NotModified, ResetContent}` reset the failure streak (and, if `IAT_SERVER_UNREACHABLE` had been reported, emit `IAT_SERVER_RECOVERED`); anything else emits `IAT_SERVER_ERROR` and, at `failureThreshold` consecutive failures, `IAT_SERVER_UNREACHABLE` (once, until a success resets it). +- A payload containing `"ModeType"` with `"ModeName":"(\w+)"` → `IAT_MODE_CHANGE` (suppressed on first observation and on unchanged mode). +- **`checkTimers()`** runs after each batch, comparing `requestAt`/`sessionAt` against **`logClock`** (not wall-clock): overdue request → `IAT_TELLER_UNANSWERED`; session with no video past `videoTimeout` → `IAT_VIDEO_SESSION_FAIL`. Driving timers off the log clock means replaying a backlog can't raise spurious timeouts. + +### `SymXchangeMonitor` — header-block parsing + +The SymXchange log alternates header lines (`[yyyy-MM-dd HH:mm:ss-nnn][OpName]`) and XML payloads (PANs/SSNs masked to `****` at source). Per line: + +- A header sets `currentTs`/`currentOp`, resets `faultReportedForBlock`, bumps `messageCount`. +- A `CONN_ERROR`-matching line (unable to connect / no connection / refused / timed out / DNS) increments a streak; at `failureThreshold` consecutive → `IAT_CORE_UNREACHABLE` (once until reset). +- Each SymXchange message **header** clears the connection-error streak — any response (incl. a fault) proves the core is reachable — and, if `IAT_CORE_UNREACHABLE` had been reported, emits `IAT_CORE_RECOVERED`. +- A `FAULT`-matching line (`faultstring`, ``, ``, ``, non-zero ``) → `IAT_CORE_TXN_FAIL` (at most once per header block). The fault/conn regexes are conservative heuristics noted for tuning against a real failure sample. + +### `CheckProcessingMonitor` — pipe-delimited records + +Each `Process Date:`-prefixed line is split on `|` into `Key: Value` fields. A check is flagged (→ `IAT_CHECK_IMAGE_QUALITY`, informational) when any of: `Status` doesn't start with `Processed`; `Invalidity Score` > `invalidityThreshold`; or `|Amount CAR − Amount LAR| >= amountMismatchCents`. Amounts are in cents and rendered as dollars in the detail text. Clean checks emit nothing. + +## How events reach the platform + +All three monitors emit through the single shared `IatEventEmitter`, which wraps the same `IncidentEventClient` class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound API surface. + +``` +monitor → emitter.emit(eventType, details, eventSource, eventTime) + → CreateJournalEventRequest.builder() + .agentAtmId(atmId) // ATM name string, resolved to numeric atmId server-side + .eventType(eventType) // free-form String, e.g. "IAT_CORE_UNREACHABLE" + .eventDetails(details) + .eventSource(eventSource) // IAT_ACTIVETELLER | IAT_SYMXCHANGE | IAT_CHECK21 + .eventTime(ts.format(ISO_LOCAL_DATE_TIME) | null) + .build() + → IncidentEventClient.sendEvent(req) + → HTTP POST {agent.endpoint or incident.endpoint}/api/journal-events +``` + +`eventType` is a plain `String` on `CreateJournalEventRequest` (the `hiveops-agent-journal` DTO) — not validated client-side. Nothing stops an unregistered `IAT_*` type from being sent; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table. On `IOException`, `emit()` logs a warning and drops the event — no retry, no queue. + +## Platform abstraction + +None used directly. The module reads plain files via `java.nio.file` / `RandomAccessFile` — no `PlatformService`/`PathResolver` dependency. The Hyosung/Windows constraint is enforced by deployment convention (installed only on `device.type=NH_IAT` machines) and the configured `activeteller.log.dir`, not by a runtime OS check in this module's code. + +## Cross-links +- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface +- [platform.module-map] diff --git a/backend/src/main/resources/content/agent/activeteller/claude.md b/backend/src/main/resources/content/agent/activeteller/claude.md new file mode 100644 index 0000000..898c44e --- /dev/null +++ b/backend/src/main/resources/content/agent/activeteller/claude.md @@ -0,0 +1,77 @@ +--- +module: agent.activeteller +title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-activeteller` source 2026-07-01. + +## Identity +- Maven artifact: **`hiveops-module-activeteller`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent` `4.4.3`). +- Agent module id (`getName()`, and the string to use in **`modules.disabled`**): **`activeteller`**. Module `getVersion()` = `1.0.0`. +- Entry class: `com.hiveops.activeteller.ActiveTellerModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`. +- Log package (`getLogPackage()`): `com.hiveops.activeteller`. +- Activation gate: `device.type` must equal `NH_IAT`; any other value → module inert, `isEnabled()` returns `false`. +- Classes: `ActiveTellerModule`, `TellerSessionMonitor`, `SymXchangeMonitor`, `CheckProcessingMonitor`, `RollingLogTailer`, `IatEventEmitter`. + +## Config properties (exact keys) +| Key | Default | Required | Notes | +|---|---|---|---| +| `device.type` | *(none)* | **yes** | must be `NH_IAT` | +| `activeteller.log.dir` | *(none)* | **yes** | base folder holding `yyyyMMdd/` daily subfolders; must exist & be a dir or module disables (warn, no crash) | +| `activeteller.poll.interval.sec` | `30` | no | **applied** in `start()` | +| `activeteller.heartbeat.interval.sec` | `900` | no | rollup heartbeat cadence | +| `activeteller.answer.timeout.sec` | `180` | no | request→assignment deadline | +| `activeteller.video.timeout.sec` | `60` | no | session→video deadline | +| `activeteller.server.failure.threshold` | `3` | no | consecutive REST fails → `IAT_SERVER_UNREACHABLE` | +| `activeteller.core.failure.threshold` | `3` | no | consecutive SymXchange conn errors → `IAT_CORE_UNREACHABLE` | +| `activeteller.check.invalidity.threshold` | `50` | no | Check21 invalidity flag threshold | +| `activeteller.check.amount.mismatch.cents` | `500` | no | CAR/LAR gap (cents) that flags a check | + +Shared (not module-specific): `agent.endpoint` / `incident.endpoint` (prefix = `agent` if `agent.endpoint` set, else `incident`), resolved via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` — same bearer-token / institution-key chain as every other event module. No IAT-specific auth keys. + +## Event types emitted (exact strings) +| Event type | `eventSource` | Trigger | +|---|---|---| +| `IAT_TELLER_UNANSWERED` | `IAT_ACTIVETELLER` | teller requested, no session assigned within `answer.timeout.sec` | +| `IAT_VIDEO_SESSION_FAIL` | `IAT_ACTIVETELLER` | session assigned but no video/remote-control within `video.timeout.sec`, or session ended with no video | +| `IAT_SERVER_ERROR` | `IAT_ACTIVETELLER` | Active Teller REST call returned non-2xx | +| `IAT_SERVER_UNREACHABLE` | `IAT_ACTIVETELLER` | `server.failure.threshold` consecutive REST failures | +| `IAT_SERVER_RECOVERED` | `IAT_ACTIVETELLER` | first REST success after an `IAT_SERVER_UNREACHABLE` (→ incident auto-close) | +| `IAT_MODE_CHANGE` | `IAT_ACTIVETELLER` | `"ModeType"` payload, `ModeName` changed (not first observation) | +| `IAT_CORE_TXN_FAIL` | `IAT_SYMXCHANGE` | SOAP fault / error line in SymXchange log (once per header block) | +| `IAT_CORE_UNREACHABLE` | `IAT_SYMXCHANGE` | `core.failure.threshold` consecutive connection errors | +| `IAT_CORE_RECOVERED` | `IAT_SYMXCHANGE` | next SymXchange message after an `IAT_CORE_UNREACHABLE` (→ incident auto-close) | +| `IAT_CHECK_IMAGE_QUALITY` | `IAT_CHECK21` | Check21 status≠Processed, invalidity>threshold, or CAR/LAR gap≥threshold | +| `IAT_HEARTBEAT` | `IAT_ACTIVETELLER` | every `heartbeat.interval.sec`, only if a monitor saw a line today | + +- `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO) — **not validated client-side**. These `IAT_*` strings are custom (not in any agent enum) — recognition is hiveops-incident's DB-backed `event_type` table. +- `CreateJournalEventRequest` fields set: `agentAtmId`, `eventType`, `eventDetails`, `eventSource`, `eventTime` (ISO_LOCAL_DATE_TIME, or null). No cassette/card fields set. +- Transport: `POST {endpoint}/api/journal-events` via `IncidentEventClient.sendEvent()` — same client/endpoint as `journal-events`. + +## Files monitored (under `{activeteller.log.dir}/{yyyyMMdd}/`) +- `ActiveTellerAgent_*.log` — newest-matching (session-stamped); `TellerSessionMonitor`, tag `agent`. +- `SymXchange{yyyyMMdd}.log` — fixed daily name; `SymXchangeMonitor`, tag `symx`. +- `CheckProcessingResults{yyyyMMdd}.log` — fixed daily name; `CheckProcessingMonitor`, tag `check`. +- All via `RollingLogTailer`: tail-by-offset (`RandomAccessFile` + `fileOffset`), reset on new-day/new-file or truncation; lines re-decoded ISO-8859-1 → UTF-8. + +## Behavior facts +- Answer/video timers use the **log clock** (timestamp of last processed line), not wall-clock — backlog replay won't raise false timeouts. +- Dedup guards: `IAT_VIDEO_SESSION_FAIL`, `IAT_SERVER_UNREACHABLE`, `IAT_CORE_UNREACHABLE`, and per-block `IAT_CORE_TXN_FAIL` each fire once until state resets. When an `*_UNREACHABLE` state resets (first REST success / next SymXchange message), the matching `IAT_SERVER_RECOVERED` / `IAT_CORE_RECOVERED` is emitted. +- REST "success" whitelist: `OK, Created, Accepted, NoContent, NotModified, ResetContent`. +- SymXchange fault/conn regexes are conservative heuristics — may need tuning against a real failure sample. + +## Deployment +- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (drops JAR into agent `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`). +- Disable without uninstalling: add `activeteller` to `modules.disabled`, push `CONFIG_UPDATE`. +- Register the eleven `IAT_*` event types in hiveops-incident → Settings → Event Types (DB `event_type` table) for correct labeling. + +## Do NOT +- Do NOT expect the module to run on any `device.type` other than `NH_IAT` — inert by design elsewhere, not a bug. +- Do NOT expect retry/queueing on send failure — a failed `sendEvent()` is logged (`activeteller: failed to send ...`) and dropped. +- Do NOT assume the `IAT_*` strings exist in an agent `EventType` enum — they don't; the incident DB `event_type` table is what gates recognition. +- Do NOT point `activeteller.log.dir` at the daily `yyyyMMdd` subfolder — it must be the **base** folder; the tailer appends `{yyyyMMdd}/` itself. +- Do NOT assume `IAT_HEARTBEAT` fires on an idle/offline machine — it's suppressed unless a monitor saw a line today. +- Do NOT treat the SymXchange fault detection as authoritative — the markers are heuristics tuned without a real fault sample. diff --git a/backend/src/main/resources/content/agent/activeteller/internal.md b/backend/src/main/resources/content/agent/activeteller/internal.md new file mode 100644 index 0000000..db619be --- /dev/null +++ b/backend/src/main/resources/content/agent/activeteller/internal.md @@ -0,0 +1,68 @@ +--- +module: agent.activeteller +title: Active Teller (IAT) — teller-session, core-banking & Check21 monitoring for Nautilus Hyosung ITMs +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-activeteller` source. Verify against code before relying on specifics. + +## What this module is + +`hiveops-module-activeteller` (Maven artifact `hiveops-module-activeteller`, agent module id **`activeteller`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **Nautilus Hyosung Active Teller Interactive/Video Teller Machines (IAT/ITM) only** — it self-disables unless the ATM's `device.type` is `NH_IAT`. + +It tails three log files that the Hyosung MoniPlus2s / Active Teller software already writes to disk and turns specific lines into HiveIQ journal events (`IAT_*`) sent to the incident pipeline via agent-proxy. The module does **not** talk to the ITM hardware, the teller/video server, or the core banking host directly — it is purely a read-only log observer. + +## What it monitors and why + +All three logs live under the same base folder configured by `activeteller.log.dir`, inside a per-day `yyyyMMdd` subfolder (the standard MoniPlus2s convention). One scheduler tick (default 30s) polls all three: + +1. **`ActiveTellerAgent_{date}_{time}.log`** (session-stamped; newest match wins) — the Active Teller client log. Reconstructs the interactive-teller session flow and reports: + - **`IAT_TELLER_UNANSWERED`** — a customer requested a teller but no teller session was assigned within `activeteller.answer.timeout.sec`. + - **`IAT_VIDEO_SESSION_FAIL`** — a teller session started but the video/remote-control session never came up (within `activeteller.video.timeout.sec`, or the session ended without video). + - **`IAT_SERVER_ERROR`** — an Active Teller REST call returned a non-2xx status. + - **`IAT_SERVER_UNREACHABLE`** — `activeteller.server.failure.threshold` consecutive REST failures. + - **`IAT_SERVER_RECOVERED`** — first successful Active Teller REST call after an `IAT_SERVER_UNREACHABLE`; lets the incident side auto-close the connectivity ticket. + - **`IAT_MODE_CHANGE`** — scheduled operating-mode transition (e.g. Standard ↔ SelfService). +2. **`SymXchange{date}.log`** (fixed daily name) — the Jack Henry **Symitar (SymXchange)** core-banking conversation. Reports: + - **`IAT_CORE_TXN_FAIL`** — a SOAP fault / error response from the core (at most once per header block). + - **`IAT_CORE_UNREACHABLE`** — `activeteller.core.failure.threshold` consecutive connection-level errors (unable to connect / timed out / refused / DNS). + - **`IAT_CORE_RECOVERED`** — next SymXchange message after an `IAT_CORE_UNREACHABLE` (a response, incl. a SOAP fault, proves the core is reachable); lets the incident side auto-close the core-banking ticket. +3. **`CheckProcessingResults{date}.log`** (fixed daily name) — Check21 image capture. Emits informational **`IAT_CHECK_IMAGE_QUALITY`** when a check deposit's invalidity score is high, the CAR/LAR (courtesy/legal amount) disagree, or the status isn't "Processed" — cases a back-office reviewer would otherwise have to catch by hand. + +A rollup **`IAT_HEARTBEAT`** is emitted every `activeteller.heartbeat.interval.sec` (default 900s / 15 min), but **only if at least one log produced a line today** — it will not send an empty heartbeat on an idle/offline machine. + +## Config properties it registers + +| Property | Default | Meaning | +|---|---|---| +| `device.type` | *(none)* | Activation gate — must equal `NH_IAT` or the module is inert. | +| `activeteller.log.dir` | *(none — required)* | Base folder that contains the `yyyyMMdd` daily subfolders (e.g. `C:/Hyosung/MoniPlus2s/MoniPlus2Slog`). If unset or not a real directory, the module logs a warning and disables itself — it does **not** crash agent startup. | +| `activeteller.poll.interval.sec` | `30` | Log poll interval. **Applied** (unlike some sibling modules). | +| `activeteller.heartbeat.interval.sec` | `900` | Rollup-heartbeat interval. | +| `activeteller.answer.timeout.sec` | `180` | Teller-request → assignment deadline before `IAT_TELLER_UNANSWERED`. | +| `activeteller.video.timeout.sec` | `60` | Session-start → video/remote-control-up deadline before `IAT_VIDEO_SESSION_FAIL`. | +| `activeteller.server.failure.threshold` | `3` | Consecutive REST failures → `IAT_SERVER_UNREACHABLE`. | +| `activeteller.core.failure.threshold` | `3` | Consecutive SymXchange connection errors → `IAT_CORE_UNREACHABLE`. | +| `activeteller.check.invalidity.threshold` | `50` | Check21 invalidity score above which a check is flagged. | +| `activeteller.check.amount.mismatch.cents` | `500` | CAR/LAR gap (in cents) that counts as a bad read. | + +It also reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), resolved through `HttpClientHelper.loadSettingsFromProperties(props, prefix)` (the same bearer-token / institution-key chain every event-reporting module uses). There are no IAT-specific auth properties. + +## How it's enabled / deployed + +- Activates only on ATMs configured with `device.type=NH_IAT`. On any other device type it logs `activeteller: device.type='', not NH_IAT — module disabled` and does nothing — expected, not a bug. +- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory and restarts the agent service (see `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order: lab ATM → pilot device → fleet. +- Can be disabled fleet-wide without removing the JAR by adding `activeteller` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE` (the id is **`activeteller`**, the module's `getName()`). +- The eleven event types it emits (`IAT_HEARTBEAT`, `IAT_TELLER_UNANSWERED`, `IAT_VIDEO_SESSION_FAIL`, `IAT_SERVER_ERROR`, `IAT_SERVER_UNREACHABLE`, `IAT_SERVER_RECOVERED`, `IAT_MODE_CHANGE`, `IAT_CORE_TXN_FAIL`, `IAT_CORE_UNREACHABLE`, `IAT_CORE_RECOVERED`, `IAT_CHECK_IMAGE_QUALITY`) are `IAT_*` custom types — they must exist as rows in hiveops-incident's `event_type` table (Incident → Settings → Event Types) to be recognized/labeled correctly downstream. They are not standard shared types. + +## Common failure modes / what to check + +- **No IAT events ever appear for a customer ITM** — first check `device.type` on that ATM's config; if it isn't `NH_IAT` the module is intentionally inert. Not a bug. +- **Module loaded but silent** — check `activeteller.log.dir` is set and points at a real, existing base directory on that machine. A missing/wrong path disables the module at startup with a log warning (no crash). Also confirm the daily `yyyyMMdd` subfolder actually exists — the tailer resolves `{log.dir}/{yyyyMMdd}/` each poll and silently no-ops if that folder isn't there. +- **Events stopped after midnight / around log rotation** — all three monitors roll to a new `yyyyMMdd` folder (and SymXchange/Check21 to a new dated filename) at local midnight; the ActiveTeller log is session-stamped, so the newest matching file wins. If the Hyosung file-naming convention ever changes, the module watches a file that never appears and goes silent. Grep agent logs for `activeteller[...]: watching ` to confirm which file each monitor is tailing. +- **Spurious `IAT_TELLER_UNANSWERED` / `IAT_VIDEO_SESSION_FAIL`** — the answer/video timers are driven off the **log clock** (timestamp of the last processed line), not wall-clock, so replaying a backlog cannot raise false timeouts. If real timeouts fire wrongly, re-check `activeteller.answer.timeout.sec` / `activeteller.video.timeout.sec` against the site's actual teller-answer SLA. +- **SymXchange faults missed or over-reported** — the fault / connection-error regexes are conservative heuristics (no real SymXchange failure was in the reference capture). If a customer's real fault has a different shape, the markers may need tuning; check `IAT_CORE_TXN_FAIL` details against the raw log. +- **Events sent but not visible/categorized in Incident** — confirm the `IAT_*` types are registered in hiveops-incident's Event Types settings; these are DB-driven, not hardcoded. +- **HTTP send failures** — `IatEventEmitter` catches and logs send failures (`activeteller: failed to send ...`) but does **not** retry or queue — a transient outage silently drops that single event rather than backing it up. diff --git a/backend/src/main/resources/content/agent/aria-signals/architect.md b/backend/src/main/resources/content/agent/aria-signals/architect.md new file mode 100644 index 0000000..bcc7928 --- /dev/null +++ b/backend/src/main/resources/content/agent/aria-signals/architect.md @@ -0,0 +1,69 @@ +--- +module: agent.aria-signals +title: ARIA Security Signals — ATM threat-signal collector +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · how it's built.** Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01. See [technical.agent] for agent-wide module-system context. + +## Maven module + +`hiveops-module-aria-signals` (artifact `hiveops-module-aria-signals`, own version `1.0.0`, parented by `hiveops-agent-parent` — currently `4.4.3`). Packaged as a standalone jar (`${project.artifactId}` → `hiveops-module-aria-signals.jar`). Dependencies are all **`provided`** scope (`hiveops-core`, `jackson-databind`, `log4j-api`, `commons-lang3`) — nothing is shaded into the module JAR; it relies on the agent runtime classpath. No dependency on `hiveops-journal`, `hiveops-images`, or `hiveops-file-collection`. + +Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.aria.AriaSignalModule`. This is how `ModuleLoader` discovers it from the `modules/` directory at startup. + +## Classes and flow + +Four runtime classes plus three DTOs: + +| Class | Role | +|---|---| +| `AriaSignalModule` | `AgentModule` implementation — reads config, wires collaborators, owns the scheduler and lifecycle | +| `AriaSignalCollector` | `Runnable` — polls the Windows Security Event Log + scans staging dirs, batches, POSTs events | +| `AriaPostureReporter` | `Runnable` — builds and POSTs the device posture snapshot | +| `AriaHttpClient` | Thin `HttpURLConnection` JSON POST client to the ARIA base URL | +| `dto.AriaIngestRequest` | Envelope: `deviceAgentId`, `deviceId`, `institutionKey`, `List events` | +| `dto.AriaSignalItem` | One signal: `signalKey`, `eventType`, `Map rawPayload`, `detectedAt` (ISO-8601 string) | +| `dto.AriaPostureReportDto` | Posture snapshot (see fields below) | + +### Lifecycle (`AriaSignalModule`) + +`getName()` = `"aria-signals"`; `getLogPackage()` = `"com.hiveops.aria"` → the framework routes its output to `logs/modules/aria-signals.log`. + +- `initialize(ModuleContext)` — reads `aria.signals.enabled`; if false, returns (leaves `ready=false`). Reads `aria.endpoint`; if blank, logs a warning and returns. Otherwise builds one `AriaHttpClient(endpoint, serviceSecret, skipSsl)`, resolves `atmName` (`context.getAtmName()`), `institutionKey` (`agent.institution.key` → `incident.institution.key`), `deviceId` (`aria.device.id`, parsed to `Long` or null), intervals and batch size, then constructs the collector and reporter and sets `ready=true`. +- `isEnabled(context)` returns the `ready` flag (so a mis-configured module self-disables rather than erroring). +- `start()` — creates a **2-thread** daemon `ScheduledExecutorService` and schedules both `Runnable`s with `scheduleAtFixedRate`: the collector at `[0, signalIntervalSec]` and the reporter at `[0, postureIntervalSec]`. So the posture report fires **immediately**; the collector's first run just **anchors** the log (see below). +- `stop()` — `scheduler.shutdownNow()`. +- `isHealthy()` — `scheduler == null || !scheduler.isTerminated()`; consulted by the framework `ModuleWatchdog`. + +### Signal collection cycle (`AriaSignalCollector`, poll-based) + +The trigger model is **poll**, not log-tail or hook. Each `run()`: + +1. **First tick anchors, doesn't collect.** If not yet `anchored`, `anchorSecurityLog()` runs `wevtutil qe Security /rd:true /f:XML /c:1 /q:` (newest first, query with no `EventRecordID` bound), parses the max `EventRecordID`, stores it in `lastSecurityRecordId`, sets `anchored=true`, and returns. This prevents replaying historical events. On non-Windows it just sets `anchored=true` and returns. +2. **Subsequent ticks:** `collectSecurityEvents()` builds a query `*[System[(EventID=1102 or … ) and EventRecordID > ]]`, runs `wevtutil qe Security /rd:false /f:XML /c: /q:`, and parses the returned XML with **hand-rolled string scanning** (`parseEvents`/`parseBlock`/`extractTag`/`extractAttr` — no XML parser dependency). Each event becomes an `AriaSignalItem` with `signalKey = `, `eventType = "OS_EVENT"`, and `rawPayload = {eventId, recordId, provider?}`. The bookmark `lastSecurityRecordId` advances to the max record seen. +3. **Staging-dir scan** (`scanStagingDirs()`, runs on all OSes but paths are Windows `C:/...`): for each existing directory, any regular file whose name matches `SUSPICIOUS_FILENAMES` (case-insensitive) emits `AriaSignalItem(signalKey="IOC_FILENAME_MATCH", eventType="FILE_SYSTEM", payload={path, filename})`. Separately, if the directory contains **any** file at all, it emits `AriaSignalItem(signalKey="SUSPICIOUS_DIRECTORY", eventType="FILE_SYSTEM", payload={directory})`. +4. If the combined list is non-empty, wrap in `AriaIngestRequest(deviceAgentId, deviceId, institutionKey, events)` and `client.sendEvents(...)`. + +Note the bookmark advances **before** the POST, so a failed send does not re-queue those Security events on the next tick. + +### Posture cycle (`AriaPostureReporter`) + +`buildReport()` populates `AriaPostureReportDto`: `deviceAgentId`/`deviceId`/`institutionKey`; `osVersion` (`os.name os.version`) and `osEolStatus` (`classifyOsEol` → `EOL` for Win 7/XP/Vista/8, else `SUPPORTED`/`UNKNOWN`). On Windows it adds `diskEncryptionEnabled` (BitLocker `manage-bde -status C:` → "protection on"), `auditPolicyCompliant` (`auditpol` → not "no auditing"), `softwareWhitelistEnabled` (`sadmin status` → "solid", fallback AppLocker registry `EnforcementMode 0x1`). If `aria.gold.image.hash` is set, it SHA-256-hashes `aria.image.path` (`sha256:`) into `currentImageHash` for drift comparison. Each check returns `null` on failure, and `@JsonInclude(NON_NULL)` drops null fields from the JSON. + +## How events reach the platform + +`AriaHttpClient` posts JSON via `HttpURLConnection` (connect 10s / read 15s) with `Content-Type: application/json; charset=UTF-8` and header `X-Service-Secret: `: + +| Method | Path (relative to `aria.endpoint`) | Body | +|---|---|---| +| `sendEvents` | `POST /api/aria/ingest/events` | `AriaIngestRequest` | +| `sendPostureReport` | `POST /api/aria/posture/report` | `AriaPostureReportDto` | + +This is a **direct** agent → ARIA backend path. It does **not** traverse agent-proxy/incident and does **not** use `agent.auth.token`, `FileUploadClient`, or the fleet-task pipeline that other modules share. Non-2xx responses are logged as warnings; there is no retry/backoff. + +## Platform abstraction + +This module deliberately **does not** use the agent's `PlatformService`/`SystemCommands` abstraction. OS branching is done inline with `System.getProperty("os.name")` and raw `ProcessBuilder` calls to `wevtutil`, `manage-bde`, `auditpol`, `sadmin`, `reg`. Command output is read with the JVM `file.encoding` charset. Consequence: all monitoring except the OS/EOL and image-hash posture fields is effectively Windows-only. diff --git a/backend/src/main/resources/content/agent/aria-signals/claude.md b/backend/src/main/resources/content/agent/aria-signals/claude.md new file mode 100644 index 0000000..5dd0677 --- /dev/null +++ b/backend/src/main/resources/content/agent/aria-signals/claude.md @@ -0,0 +1,85 @@ +--- +module: agent.aria-signals +title: ARIA Security Signals — ATM threat-signal collector +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01. + +## Identity +- Module name (`AgentModule.getName()`): **`aria-signals`** +- Maven artifact: **`hiveops-module-aria-signals`** (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`) +- Entry class: `com.hiveops.aria.AriaSignalModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`) +- Other classes: `AriaSignalCollector`, `AriaPostureReporter`, `AriaHttpClient`; DTOs `AriaIngestRequest`, `AriaSignalItem`, `AriaPostureReportDto` +- Log package (`getLogPackage()`): `com.hiveops.aria` → `logs/modules/aria-signals.log` +- Drop-in JAR: `modules/hiveops-module-aria-signals.jar` (all deps `provided`; not in fat JAR) +- **Disabled by default.** No fleet-task kind; passive scheduled collector. + +## Config property keys (all in `hiveops.properties`) +| Key | Default | Notes | +|---|---|---| +| `aria.signals.enabled` | `false` | master switch | +| `aria.endpoint` | *(none, required)* | ARIA base URL; blank → module inactive | +| `aria.service.secret` | `""` | sent as `X-Service-Secret` header | +| `aria.signals.poll.interval.sec` | `60` | event/staging poll | +| `aria.posture.report.interval.sec` | `86400` | posture report (24h) | +| `aria.signals.batch.size` | `50` | `wevtutil /c:` cap per poll | +| `aria.gold.image.hash` | *(none)* | enables image-drift check when set | +| `aria.image.path` | *(none)* | file hashed (SHA-256) vs gold hash | +| `aria.ssl.skip.verify` | `false` | installs **JVM-wide** trust-all SSL | +| `aria.device.id` | *(none)* | numeric; else omitted from reports | +| `agent.institution.key` → `incident.institution.key` | *(none)* | institution key | + +- `isEnabled()` == true only when `aria.signals.enabled=true` AND `aria.endpoint` non-blank. +- `deviceAgentId` = `ModuleContext.getAtmName()`. No dedicated `aria.*` interval for staging scan (shares `aria.signals.poll.interval.sec`). + +## Signal strings emitted (`AriaSignalItem.signalKey` / `.eventType`) +| signalKey | eventType | Source | +|---|---|---| +| `"1102"`,`"4719"`,`"6416"`,`"2003"`,`"4663"`,`"4688"`,`"4697"` | `OS_EVENT` | Windows Security Event Log (`wevtutil`) | +| `IOC_FILENAME_MATCH` | `FILE_SYSTEM` | suspicious filename in a staging dir | +| `SUSPICIOUS_DIRECTORY` | `FILE_SYSTEM` | staging dir contains ANY file | + +- Monitored Security Event IDs: `1102, 4719, 6416, 2003, 4663, 4688, 4697` (`SECURITY_EVENT_IDS`). +- Staging dirs (`STAGING_DIRS`): `C:/Temp/bin`, `C:/Windows/Temp/s`, `C:/ProgramData/s`, `C:/ProgramData/Microsoft/Wms`, `C:/Windows/system32/bin`. +- Suspicious filenames (case-insensitive): `Newage.exe`, `Color.exe`, `sdelete.exe`, `atmapp.exe`, `mainclientd.exe`, `ATMContent.exe`. +- Posture DTO fields: `diskEncryptionEnabled`, `auditPolicyCompliant`, `softwareWhitelistEnabled`, `osVersion`, `osEolStatus` (`SUPPORTED|EOL|UNKNOWN`), `goldImageHash`, `currentImageHash` (`sha256:`). + +## HTTP endpoints (agent → ARIA backend, DIRECT) +| Call | Method + path (relative to `aria.endpoint`) | +|---|---| +| Events | `POST /api/aria/ingest/events` (`AriaIngestRequest`) | +| Posture | `POST /api/aria/posture/report` (`AriaPostureReportDto`) | +- Header: `X-Service-Secret: `. Timeouts: connect 10s / read 15s. Non-2xx logged as warning, **no retry**. +- Does **NOT** go through agent-proxy/incident; does **NOT** use `agent.auth.token` or `FleetTask`/`FileUploadClient`. + +## Posture check commands (Windows, raw `ProcessBuilder`) +- BitLocker: `manage-bde -status C:` → true if output contains "protection on" +- Audit policy: `auditpol /get /subcategory:"Audit Policy Change"` → true unless "no auditing" +- Whitelisting: `sadmin status` → "solid"; fallback `reg query HKLM\...\SrpV2\Exe /v EnforcementMode` → contains `0x1` +- Each returns `null` on failure; `@JsonInclude(NON_NULL)` drops nulls. + +## Deployment +- Enable: `CONFIG_UPDATE` fleet task setting `aria.signals.enabled=true` + `aria.endpoint` (+ secret), then agent restart. +- Retrofit missing JAR: `INSTALL_MODULE` fleet task (drops JAR into `modules/`, restarts agent) or full agent update. +- Disable: `aria.signals.enabled=false` OR add `aria-signals` to `modules.disabled`. + +## Gotchas +- **First collector tick only anchors the log** (records max `EventRecordID`, sends nothing). First real events arrive on the 2nd tick (after `aria.signals.poll.interval.sec`). Posture report fires immediately on start. +- **Windows-only in practice:** event-log poll + staging scan produce nothing on Linux. Posture report runs anywhere but only OS/EOL + image-hash fields populate off-Windows (BitLocker/audit/whitelist are Windows-guarded). +- `SUSPICIOUS_DIRECTORY` fires if the staging dir holds **any** file, not just a suspicious one → noisy. +- Record bookmark (`lastSecurityRecordId`) advances **before** the POST; a failed send loses those events (no re-queue). +- `aria.ssl.skip.verify=true` installs a trust-all `SSLContext` via `HttpsURLConnection.setDefault*` — affects the **entire JVM**, not just ARIA calls. +- XML is parsed by hand-rolled string scanning (`extractTag`/`extractAttr`), not a real XML parser. +- No `PlatformService`/`SystemCommands` usage — inline `os.name` + `ProcessBuilder`. + +## Do NOT +- Do NOT expect `EventType` journal/incident events (`CARD_READER_FAIL`, `IAT_*`, etc.) — this module emits ARIA `signalKey`/`eventType` (`OS_EVENT`/`FILE_SYSTEM`) only, direct to ARIA. +- Do NOT look for these calls in agent-proxy/incident or fleet-task logs — they bypass that pipeline entirely. +- Do NOT assume `agent.auth.token` authenticates ARIA calls — it's `X-Service-Secret` only. +- Do NOT expect data on the first poll or on Linux — anchor-first + Windows-centric. +- Do NOT enable `aria.ssl.skip.verify` casually — it disables TLS verification JVM-wide. +- Do NOT assume delivery is guaranteed — best-effort, no retry, bookmark advances past failed sends. +- Do NOT invent extra `aria.*` keys — only the eleven above exist in code. diff --git a/backend/src/main/resources/content/agent/aria-signals/internal.md b/backend/src/main/resources/content/agent/aria-signals/internal.md new file mode 100644 index 0000000..5123f3a --- /dev/null +++ b/backend/src/main/resources/content/agent/aria-signals/internal.md @@ -0,0 +1,82 @@ +--- +module: agent.aria-signals +title: ARIA Security Signals — ATM threat-signal collector +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support view.** Confirmed against `hiveops-module-aria-signals` source (`com.hiveops.aria`) 2026-07-01. + +## What it does (and doesn't) + +`hiveops-module-aria-signals` (module name `aria-signals`, class `AriaSignalModule`) is a **passive security-signal collector** that feeds the HiveIQ **ARIA** threat-intelligence backend. On each ATM it runs two scheduled jobs: + +1. **Signal collector** (`AriaSignalCollector`, default every **60s**) — polls the Windows **Security Event Log** for a fixed set of threat-relevant Event IDs, and scans a fixed list of known malware **staging directories** for suspicious filenames. Batches whatever it finds and POSTs it to ARIA. +2. **Posture reporter** (`AriaPostureReporter`, default every **24h**) — collects a device **security posture** snapshot (BitLocker disk encryption, audit policy, software whitelisting, OS/EOL status, gold-image hash drift) and POSTs it to ARIA. + +It is **disabled by default** and is **Windows-centric**: the event-log poll and staging-dir scan only produce data on Windows; on Linux the posture reporter still runs but only fills in OS/EOL and image-hash fields. + +Important boundary: unlike the journal/incident modules, this module does **not** emit `EventType` journal events to hiveops-incident and does **not** go through agent-proxy. It POSTs **directly** to the ARIA base URL (`aria.endpoint`, e.g. `https://api.bcos.cloud`) authenticated with an `X-Service-Secret` header. See [architect.md] for the flow. + +### What it monitors + +**Security Event Log IDs** (`AriaSignalCollector.SECURITY_EVENT_IDS`): + +| Event ID | Meaning | +|---|---| +| 1102 | Audit log cleared | +| 4719 | Audit policy changed | +| 6416 | New removable (USB) device recognised | +| 2003 | Network setting changed | +| 4663 | File/object access attempt | +| 4688 | New process created | +| 4697 | Service installed on system | + +**Staging directories scanned** (jackpotting / Ploutus-family paths — `STAGING_DIRS`): `C:/Temp/bin`, `C:/Windows/Temp/s`, `C:/ProgramData/s`, `C:/ProgramData/Microsoft/Wms`, `C:/Windows/system32/bin`. Suspicious filenames flagged (`SUSPICIOUS_FILENAMES`, case-insensitive): `Newage.exe`, `Color.exe`, `sdelete.exe`, `atmapp.exe`, `mainclientd.exe`, `ATMContent.exe`. + +**Posture checks** (Windows, via `ProcessBuilder`): BitLocker (`manage-bde -status C:`), audit policy (`auditpol /get /subcategory:"Audit Policy Change"`), software whitelisting (McAfee Solidcore/Trellix `sadmin status`, falling back to Windows AppLocker registry `SrpV2\Exe EnforcementMode`). OS/EOL classification and optional gold-image SHA-256 drift run on all platforms. + +## Config properties it registers + +All keys live in `hiveops.properties` and are read in `AriaSignalModule.initialize()`: + +| Key | Default | Purpose | +|---|---|---| +| `aria.signals.enabled` | `false` | Master switch — module stays inactive unless `true` | +| `aria.endpoint` | *(none — required)* | ARIA base URL, e.g. `https://api.bcos.cloud`. If unset, module logs a warning and stays inactive | +| `aria.service.secret` | `""` | Value sent in the `X-Service-Secret` header on every ARIA POST | +| `aria.signals.poll.interval.sec` | `60` | Event-log / staging-dir poll interval | +| `aria.posture.report.interval.sec` | `86400` | Posture report interval (default 24h) | +| `aria.signals.batch.size` | `50` | Max Security events pulled per poll (`wevtutil /c:`) | +| `aria.gold.image.hash` | *(none)* | Expected SHA-256 for image-drift check; only checked if set | +| `aria.image.path` | *(none)* | File hashed and compared against `aria.gold.image.hash` | +| `aria.ssl.skip.verify` | `false` | Skip TLS cert verification (installs a JVM-wide trust-all — see gotchas) | +| `aria.device.id` | *(none)* | Optional numeric HiveIQ device id included in reports | +| `agent.institution.key` (falls back to `incident.institution.key`) | *(none)* | Institution key stamped on every report | + +Device identity on the wire: `deviceAgentId` = the agent's ATM name (`ModuleContext.getAtmName()`); `deviceId` = `aria.device.id` if numeric, else omitted. + +## Enabled / deployed to ATMs + +- **Enable:** push `aria.signals.enabled=true` **and** `aria.endpoint=...` (plus `aria.service.secret`) via a `CONFIG_UPDATE` fleet task, then restart the agent so the module re-initializes. `isEnabled()` returns true only when both `aria.signals.enabled=true` and `aria.endpoint` is non-blank. +- **Shipped how:** it is a **drop-in JAR** — `modules/hiveops-module-aria-signals.jar` (own artifact version `1.0.0`, parented by `hiveops-agent-parent`). It is **not** baked into the fat JAR. Discovered at startup via ServiceLoader from the `modules/` directory. +- **Retrofit to an ATM that doesn't have the JAR yet:** deliver via a fleet **`INSTALL_MODULE`** task (drops the JAR into `modules/` and restarts the agent), or via a full agent update that includes it in the module set. +- **Disable again:** either set `aria.signals.enabled=false`, or add `aria-signals` to `modules.disabled` (comma-separated) via `CONFIG_UPDATE`. + +## Common failure modes / what to check + +| Symptom | Likely cause | What to check | +|---|---|---| +| Module logs "disabled via aria.signals.enabled" and does nothing | `aria.signals.enabled` not `true` | Confirm the property on that ATM after the `CONFIG_UPDATE` landed and the agent restarted | +| Module logs "aria.endpoint not configured, module inactive" | `aria.endpoint` blank even though enabled | Set `aria.endpoint` and restart | +| No events ever arrive at ARIA, no errors | ATM is Linux, or genuinely no matching events; also the **first poll only anchors** the log (no data until the 2nd tick) | Confirm OS is Windows; wait past one `aria.signals.poll.interval.sec`; check `logs/modules/aria-signals.log` for "anchored Security log at RecordID" | +| Posture report fields all null on Windows | `manage-bde` / `auditpol` / `sadmin` not on PATH or agent lacks privileges | Each check returns null on failure — run the commands manually as the agent service account | +| `ARIA POST ... returned HTTP 4xx/5xx` in log | Wrong `aria.endpoint`, bad/missing `aria.service.secret`, or ARIA backend down | Non-2xx is logged as a warning and **not retried**; verify endpoint + secret | +| TLS errors on the POST | ARIA cert not trusted | Fix the trust store; only use `aria.ssl.skip.verify=true` as a last resort (it disables verification JVM-wide) | +| Flood of `SUSPICIOUS_DIRECTORY` signals | A monitored staging dir contains *any* file — the directory is flagged even when the file itself isn't on the suspicious list | Inspect the actual `directory` path; this is expected behaviour, not a bug | +| Events dropped after a failed send | The record bookmark advances before the POST; if the POST fails those events aren't re-sent | Check network to `aria.endpoint`; treat ARIA as best-effort telemetry, not guaranteed delivery | + +## Not applicable here + +No journal/incident (`EventType`) integration, no fleet-task execution. Its only output is HTTP POSTs to ARIA (`/api/aria/ingest/events`, `/api/aria/posture/report`). diff --git a/backend/src/main/resources/content/agent/atec-tcr-events/architect.md b/backend/src/main/resources/content/agent/atec-tcr-events/architect.md new file mode 100644 index 0000000..3094d54 --- /dev/null +++ b/backend/src/main/resources/content/agent/atec-tcr-events/architect.md @@ -0,0 +1,96 @@ +--- +module: agent.atec-tcr-events +title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-atec-tcr-events` source. Verify against code before relying on specifics. + +## Module shape + +Package `com.hiveops.tcrevents`, three classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]): + +- **`TcrEventsModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`) and the shared `ScheduledExecutorService`. +- **`CstSnsLogMonitor`** (package-private) — parses `CST_SNR_MMDD.log`. +- **`TcrAccessLogMonitor`** (package-private) — parses `ACESSLog.txt` (code: `logDir.resolve("ACESSLog" + mmdd + ".txt")` — **no underscore** before the date; the class javadoc's `ACESSLog_MMDD.txt` is stale). + +Both monitors are plain constructed objects (`new CstSnsLogMonitor(logDir, atmName, client)`), not their own `AgentModule`s — `TcrEventsModule` drives both from a single poll loop. + +## Flow: poll → parse → detect → emit + +``` +TcrEventsModule.start() + → ScheduledExecutorService (daemon thread "tcr-events", fixed-rate, every 30s — see internal.md re: config bug) + → poll() + → cstMonitor.poll() // CstSnsLogMonitor + → accessMonitor.poll() // TcrAccessLogMonitor +``` + +Each monitor uses **tail-by-offset** log reading, the same pattern as the agent's journal readers: +- Tracks `currentFile` + `fileOffset` (byte position) per monitor. +- On each `poll()`, opens the file with `RandomAccessFile`, seeks to `fileOffset`, reads new lines to EOF, advances `fileOffset` to `raf.getFilePointer()`. +- Daily rotation: `rotateDailyFileIfNeeded()` compares `LocalDate.now()` to the last-seen date; on a new day it recomputes the filename (`CST_SNR_.log` / `ACESSLog.txt` — the date is `MMdd` via `DateTimeFormatter.ofPattern("MMdd")`) and resets `fileOffset` to 0. +- Truncation guard: if `raf.length() < fileOffset` (file was truncated/replaced mid-session), `CstSnsLogMonitor` resets offset + in-progress parse state; `TcrAccessLogMonitor` resets offset only (no block state to clear). + +### `CstSnsLogMonitor` — block-based parsing + +The CST_SNR log groups related lines into operation blocks: +``` +>>>>> HH:MM:SS.mmm - START (OP = RP/Replenishment, TD/Teller Deposit, BC/Bill Count, ...) +... +[N_E] AH: NORM AL: EMPT BH: NORM ... +... +>>>>> HH:MM:SS.mmm - END +``` +- `BLOCK_START` / `BLOCK_END` regexes toggle an `inBlock` flag and accumulate `blockLines`. +- On `BLOCK_END`, `parseBlock()` finds the single `[N_E]` line, tokenizes `SLOT: STATUS` pairs (`SLOT_TOKEN` regex), and diffs each slot against an in-memory `Map slotStatus` — **only emits when status actually changes** (no dedup on the server side, so this in-agent memory is the only guard against event storms). +- Status → event mapping: `EMPT`→`CASSETTE_EMPTY`, `SNRF`→`TCR_SENSOR_FAULT`, `TMAN`→`TCR_MANUAL_REQUIRED`, `NORM`→ log-only (no event) recovery notice. +- A separate `MISMATCH` regex is checked on **every line independent of block state** (not gated by `inBlock`) — catches `** CST - SNR deposit count & 80 data deposit count mismatch! ... - - ` and emits `TCR_SENSOR_MISMATCH` immediately. +- `slotStatus` map is per-`CstSnsLogMonitor` instance (i.e. per-agent-process lifetime), cleared on file truncation or day rollover — so a slot that was `EMPT` yesterday will re-emit `CASSETTE_EMPTY` today if it's still empty at the first block of the new day (this is intentional: state doesn't carry across the daily file rotation). + +### `TcrAccessLogMonitor` — line-based parsing + +Simpler: no blocks, just substring matches per line: +- `"Process restarted from abnormal termination"` → `TCR_PROCESS_RESTART` +- `"## Shutdown ##"` → `TCR_SHUTDOWN` (message: "TCR operator shutdown") +- `"## Shutdown for reboot ##"` → `TCR_SHUTDOWN` (message: "TCR shutdown for reboot" — same event type, different `eventDetails` text based on whether the line contains `"reboot"`) + +No de-dup logic here — every matching line emits, every poll cycle it appears in the unread tail. + +## How events reach the platform + +Both monitors emit through the same shared `IncidentEventClient` instance (constructed once in `TcrEventsModule.initialize()`), which is the same client class used by `journal-events` and other event-reporting modules — see [technical.agent] for the full outbound API surface. + +``` +CstSnsLogMonitor/TcrAccessLogMonitor.emit(eventType, details, ...) + → CreateJournalEventRequest.builder() + .agentAtmId(atmId) // ATM name string, not incident's numeric atmId — resolved server-side + .eventType(eventType) // free-form String, e.g. "TCR_SENSOR_FAULT" — not validated client-side + .eventDetails(details) + .cassettePosition(slot) // CstSnsLogMonitor only; null for TcrAccessLogMonitor events + .eventSource("TCR_CST_SNR" | "TCR_ACESS_LOG") + .build() + → IncidentEventClient.sendEvent(req) + → HTTP POST {agent.endpoint or incident.endpoint}/api/journal-events +``` + +`eventType` on `CreateJournalEventRequest` is a plain `String` field (`hiveops-agent-journal`'s DTO) — the agent's own `EventType` enum (`com.hiveops.events.EventType`) does **not** include the five `TCR_*` values this module emits (only `CASSETTE_EMPTY` overlaps with the enum). Nothing client-side stops an unregistered type from being sent; recognition/labeling happens server-side against hiveops-incident's DB-backed `event_type` table. + +## Auth / endpoint selection + +`initialize()` picks the property prefix based on whether `agent.endpoint` is set: +```java +String prefix = agentEndpoint != null ? "agent" : "incident"; +HttpClientSettings incidentSettings = HttpClientHelper.loadSettingsFromProperties(props, prefix); +``` +`HttpClientHelper.loadSettingsFromProperties` resolves institution key with fallback order `{prefix}.institution.key` → `agent.institution.key` → `incident.institution.key`, and always reads the bearer token from `agent.auth.token` regardless of prefix. This module has no bespoke auth handling — it's 100% the shared agent HTTP settings pattern. + +## Platform abstraction + +None used directly. The module reads plain files via `java.nio.file` / `RandomAccessFile` — no `PlatformService`/`PathResolver` dependency, even though it's Windows-only in practice (the LG TCR software and its log format only exist on ATEC Windows kiosks). The Windows-only constraint is enforced by convention/deployment (only installed on `device.type=ATEC_TCR` machines) rather than by a runtime OS check in this module's code. + +## Cross-links +- [technical.agent] — module system, lifecycle, shared HTTP client settings, full outbound API surface +- [platform.module-map] diff --git a/backend/src/main/resources/content/agent/atec-tcr-events/claude.md b/backend/src/main/resources/content/agent/atec-tcr-events/claude.md new file mode 100644 index 0000000..4e7ca57 --- /dev/null +++ b/backend/src/main/resources/content/agent/atec-tcr-events/claude.md @@ -0,0 +1,62 @@ +--- +module: agent.atec-tcr-events +title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-atec-tcr-events` source 2026-07-01. + +## Identity +- Maven artifact: **`hiveops-module-atec-tcr-events`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent`). +- Agent module id (`getName()`, and the string to use in **`modules.disabled`**): **`tcr-events`** — NOT `atec-tcr-events`. +- Entry class: `com.hiveops.tcrevents.TcrEventsModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`. +- Log package: `com.hiveops.tcrevents`. +- Activation gate: `device.type` must equal `ATEC_TCR` (or legacy `TCR`); any other value → module inert, `isEnabled()` returns `false`. +- Platform: Windows-only in practice (no runtime OS check in code — enforced by deployment convention). + +## Config properties (exact keys) +| Key | Default | Required | Notes | +|---|---|---|---| +| `tcr.events.enabled` | `true` | no | `false` (case-insensitive) disables the module | +| `tcr.log.dir` | *(none)* | **yes** | must exist and be a directory or module disables itself with a warning log, no crash | +| `tcr.events.poll.interval.sec` | `30` | no | **read but NOT applied** — `start()` hardcodes `DEFAULT_POLL_SEC=30`; changing this property does nothing today | + +Shared (not module-specific): `agent.endpoint` / `incident.endpoint` (prefix selection: `agent` if `agent.endpoint` is set, else `incident`), `agent.auth.token`, `agent.institution.key` / `incident.institution.key`. + +## Event types emitted (exact strings) +| Event type | Source file | Trigger | +|---|---|---| +| `CASSETTE_EMPTY` | `CST_SNR_MMDD.log` | slot status transitions to `EMPT` | +| `TCR_SENSOR_FAULT` | `CST_SNR_MMDD.log` | slot status transitions to `SNRF` | +| `TCR_MANUAL_REQUIRED` | `CST_SNR_MMDD.log` | slot status transitions to `TMAN` | +| `TCR_SENSOR_MISMATCH` | `CST_SNR_MMDD.log` | line matches `** CST - SNR deposit count ... mismatch ...` | +| `TCR_PROCESS_RESTART` | `ACESSLog.txt` | line contains `Process restarted from abnormal termination` | +| `TCR_SHUTDOWN` | `ACESSLog.txt` | line contains `## Shutdown ##` or `## Shutdown for reboot ##` | + +- `eventType` is a free-form `String` on `CreateJournalEventRequest` (`hiveops-agent-journal` DTO) — **not validated client-side**. +- The agent's own `com.hiveops.events.EventType` enum only contains `CASSETTE_EMPTY` from this list — the five `TCR_*` strings are **not** in that enum. Recognition happens in hiveops-incident's DB-backed `event_type` table, not agent code. +- `eventSource` sent: `"TCR_CST_SNR"` (cassette log events) or `"TCR_ACESS_LOG"` (access log events). +- `cassettePosition` (slot, e.g. `AH`, `AL`) is only populated for `CstSnsLogMonitor` events; null for access-log events. +- Transport: `POST {endpoint}/api/journal-events` via `IncidentEventClient.sendEvent()` — same client/endpoint as `journal-events` module. +- Recovery to `NORM` status emits **no event** — agent-log-only (`slot {} recovered`). + +## Files monitored +- `{tcr.log.dir}/CST_SNR_.log` — cassette sensor/status log (daily rotation; date = `MMdd`, e.g. `CST_SNR_0701.log`). +- `{tcr.log.dir}/ACESSLog.txt` — TCR process/access log (daily rotation; **no underscore** in code: `ACESSLog0701.txt`). +- Both use tail-by-offset (`RandomAccessFile` + stored `fileOffset`), reset on daily rollover or file truncation. + +## Deployment +- Delivered fleet-wide via **`INSTALL_MODULE`** fleet task (drops JAR into agent's `modules/` dir, restarts agent — `ProcessInstallModule` in `hiveops-file-collection`). +- Disable without uninstalling: add `tcr-events` to `modules.disabled`, push `CONFIG_UPDATE`. +- Event types must be registered in hiveops-incident → Settings → Event Types (DB `event_type` table) to be recognized/labeled correctly. + +## Do NOT +- Do NOT use `atec-tcr-events` as the `modules.disabled` value — the actual module id is `tcr-events`. +- Do NOT expect changing `tcr.events.poll.interval.sec` to change the poll cadence — it's a known no-op bug; cadence is always 30s. +- Do NOT assume the five `TCR_*` event type strings exist in the agent's `EventType` enum — they don't; only `CASSETTE_EMPTY` does. Don't "fix" this by adding them there — the enum isn't what gates recognition (the DB `event_type` table in hiveops-incident is). +- Do NOT expect this module to run on non-`ATEC_TCR` device types — it's a no-op by design elsewhere, not a bug to chase. +- Do NOT expect retry/queueing on send failure — a failed `sendEvent()` call is logged and dropped, not persisted or retried on the next poll. +- Do NOT look for an underscore in the access-log filename — the code is `ACESSLog.txt` (e.g. `ACESSLog0701.txt`), not `ACESSLog_0701.txt`; the class javadoc's `ACESSLog_MMDD.txt` is stale. +- Do NOT confuse this with `hiveops-module-nh-tcr-events` (`NhTcrEventsModule`) or `hiveops-module-nh-tcr-journal` (`NhTcrJournalModule`) — separate modules, separate vendor (NH TCR), different log formats and event pipelines. diff --git a/backend/src/main/resources/content/agent/atec-tcr-events/internal.md b/backend/src/main/resources/content/agent/atec-tcr-events/internal.md new file mode 100644 index 0000000..f1275de --- /dev/null +++ b/backend/src/main/resources/content/agent/atec-tcr-events/internal.md @@ -0,0 +1,54 @@ +--- +module: agent.atec-tcr-events +title: ATEC TCR Events — cassette + process monitoring for LG Teller Cash Recyclers +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-atec-tcr-events` source. Verify against code before relying on specifics. + +## What this module is + +`hiveops-module-atec-tcr-events` (Maven artifact `hiveops-module-atec-tcr-events`, agent module id **`tcr-events`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process on **ATEC/LG TCR (Teller Cash Recycler) devices only** — Windows-only, and it self-disables unless the ATM's `device.type` is `ATEC_TCR` (legacy alias `TCR`). + +It tails two log files the LG TCR software already writes on disk and turns specific lines into HiveIQ journal events sent to the incident pipeline — the module does not talk to the recycler hardware directly, and it does not touch cassette counts/cash totals (that's reconciliation's job, not this module's). + +## What it monitors + +Two independent pollers, both driven by a single scheduler tick (default every 30s): + +1. **`CST_SNR_MMDD.log`** (daily-rotated) — cassette sensor/status log. The module watches for: + - Per-slot status transitions inside `[N_E]` lines (e.g. `AH: NORM AL: EMPT`) — only emits when a slot's status *changes*, so a slot stuck in `EMPT` doesn't re-fire every poll. + - A `SNR deposit count ... mismatch` warning line (deposit-count sensor vs. transaction-data mismatch). +2. **`ACESSLog.txt`** (daily-rotated — note the firmware spelling `ACESS` and, in the code, **no underscore** before the date: e.g. `ACESSLog0701.txt`) — TCR process/access log. The module watches for: + - `Process restarted from abnormal termination` + - `## Shutdown ##` / `## Shutdown for reboot ##` + +Both files are expected in the **same directory**, configured once via `tcr.log.dir`. + +## Config properties it registers + +| Property | Default | Meaning | +|---|---|---| +| `tcr.events.enabled` | `true` | Master on/off switch for this module (checked in addition to the `device.type` gate). | +| `tcr.log.dir` | *(none — required)* | Directory containing `CST_SNR_.log` and `ACESSLog.txt`. If unset or not a real directory, the module logs a warning and disables itself — it does **not** throw/crash agent startup. | +| `tcr.events.poll.interval.sec` | `30` | Poll interval in seconds. **Known bug:** `initialize()` reads this value only to log it — `start()` schedules the poller with the hardcoded `DEFAULT_POLL_SEC` (30s) regardless of what's configured. Changing this property currently has no effect. | + +It also reuses the shared agent HTTP settings — `agent.endpoint` (preferred) or `incident.endpoint` (legacy fallback), `agent.auth.token`, and the institution-key chain (`agent.institution.key` / `incident.institution.key`) — the same properties every other event-reporting module uses. There are no TCR-specific auth properties. + +## How it's enabled / deployed + +- It only activates on ATMs configured with `device.type=ATEC_TCR` (or the legacy `TCR` value). On any other device type it logs `device.type is '', not ATEC_TCR — module disabled` and does nothing — this is expected, not a bug, on non-TCR machines. +- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory and restarts the agent service (see `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet. +- Can be disabled fleet-wide without removing the JAR by adding `tcr-events` to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE` (module id for `modules.disabled` is **`tcr-events`**, not `atec-tcr-events` — see Claude doc for the exact string). +- Event types it emits (`CASSETTE_EMPTY`, `TCR_SENSOR_FAULT`, `TCR_MANUAL_REQUIRED`, `TCR_SENSOR_MISMATCH`, `TCR_PROCESS_RESTART`, `TCR_SHUTDOWN`) must exist as rows in hiveops-incident's `event_type` table (Incident → Settings → Event Types) for them to be recognized/labeled properly downstream. `CASSETTE_EMPTY` is a standard type shared with other modules; the five `TCR_*` types are specific to this module and may need to be added manually on a new environment. + +## Common failure modes / what to check + +- **No TCR events ever appear for a customer ATM** — first check `device.type` on that ATM's config; if it isn't `ATEC_TCR`/`TCR`, the module is intentionally inert. Not a bug. +- **Module loaded but silent** — check `tcr.log.dir` is set and points at a real, existing directory on that machine; a missing/wrong path disables the module at startup with a log warning, no crash. +- **Events stopped after midnight / around log rotation** — both monitors roll to a new `MMDD`-named file at local midnight; if the LG TCR software's file-naming convention ever changes (e.g. non-standard rotation, DST edge case), the module will watch a file that never appears and go silent. Check agent logs for `tcr-events: watching ` to confirm which file it's tailing. +- **Events sent but not visible/categorized in Incident** — check that `CASSETTE_EMPTY`, `TCR_SENSOR_FAULT`, `TCR_MANUAL_REQUIRED`, `TCR_SENSOR_MISMATCH`, `TCR_PROCESS_RESTART`, `TCR_SHUTDOWN` are registered in hiveops-incident's Event Types settings — these are DB-driven, not hardcoded in incident's code. +- **HTTP send failures** — `IncidentEventClient.sendEvent()` failures are caught and logged (`tcr-events: failed to send ...`) but not retried or queued; a transient outage silently drops that single event rather than backing it up for a later poll. +- **Slot recovered (NORM) events don't create tickets** — by design; recovery only writes an agent-side log line (`slot {} recovered`), no event is emitted for a return to `NORM`. diff --git a/backend/src/main/resources/content/agent/atm-config-sync/architect.md b/backend/src/main/resources/content/agent/atm-config-sync/architect.md new file mode 100644 index 0000000..f7a500d --- /dev/null +++ b/backend/src/main/resources/content/agent/atm-config-sync/architect.md @@ -0,0 +1,107 @@ +--- +module: agent.atm-config-sync +title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-atm-config-sync` source. Verify against code before relying on specifics. + +## Module shape + +Package `com.hiveops.configsync`, a **single class**, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]): + +- **`AtmConfigSyncModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule`). Owns the full lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`), the `IncidentEventClient`, and a single-thread `ScheduledExecutorService`. + +No separate monitors or parsers of its own — it **orchestrates existing agent parsers/readers** (from `hiveops-version`, `hiveops-agent-journal`, and the platform layer) and assembles their output into one DTO. All heavy lifting (applog parsing, MoniPlus2S parsing, cassette/version reads) is delegated to shared, `provided`-scope components supplied by the fat JAR at runtime. + +## Lifecycle & flow + +Unlike the log-tailing modules, this is a **poll-and-assemble-then-POST** module, not a log tail: + +``` +initialize(ctx) + → resolve endpoint: agent.endpoint (preferred) else incident.endpoint; if neither → return (ready=false) + → prefix = agent.endpoint != null ? "agent" : "incident" + → HttpClientSettings via HttpClientHelper.loadSettingsFromProperties(props, prefix) + → new IncidentEventClient(settings, atmName, country) + → ready = true + → syncAtmConfig() // one immediate sync at startup + +isEnabled(ctx) → ready // true only if an endpoint was resolved + +start() + → ConfigResyncRegistry.register(this::syncAtmConfig) // on-demand RESYNC_CONFIG hook + → ScheduledExecutorService (daemon thread "config-resync") + .scheduleAtFixedRate(syncAtmConfig, initialDelay=0, period=6h) + +stop() → scheduler.shutdownNow() +``` + +### `syncAtmConfig()` — the one method that does everything + +``` +awaitReconnectJitter() // ConnectionStateTracker — post-reconnect anti-stampede +AtmConfigSyncRequest req = new(...) + req.atmName / country / agentVersion // from ModuleContext + agentContext.getVersion() + req.startupDetectionMethod // startup-info.properties → startup.detection.method (if file exists) + req.timezone = TimeZone.getDefault().getID() + req.institutionKey // incident.institution.key (only) + // platform ConfigurationReader (Optional-returning reads, each set only if present): + req.systemName / customizingVersion / hotfixVersion / countrySpecificVersion + req.cassettes = readCassetteDenominations() → List(position, currency, value) + // applog file (only if applog.events.dir set): + req.hyosungVersion = HyosungVersionParser.parseFromFile(applogFile) // app/SDK/GSDK/Nextware + req.systemInfo = AppLogHeaderParser.parseFromFile(applogFile) // OS + installed programs/packages + // MoniPlus2S dir (moniplus2s.dir, or C:\Hyosung\MoniPlus2S on Windows): + req.moniplus2sConfig = MoniPlus2SConfigParser.parse() + req.cassettes (fallback) = parseCassettesFromHostNoteTypes(...) // only if cassettes still empty + req.moniplus2sLayeredConfig = MoniPlus2SConfigParser.parseAllLayers() +client.syncConfig(req) // POST — see below +``` + +Every step is best-effort: a null/absent source is skipped, and the whole body is wrapped in try/catch that logs `ATM config sync failed: ` (no rethrow, no retry queue). The scheduler's next 6h tick is the retry. + +### Applog file resolution +`applog.events.dir` + `applog.events.filename.format` (default `APLog{YYYY}{MM}{DD}.log`) feed an `AppLogSource(dir, format, atmName)`, whose `getCurrentLogFile()` returns the file for today. That single file is handed to both `HyosungVersionParser` and `AppLogHeaderParser`. + +### MoniPlus2S path handling +Java `.properties` parsing strips single backslashes, so `moniplus2s.dir=C:\Hyosung\MoniPlus2S` arrives as `C:HyosungMoniPlus2S`. The module checks `new File(dir).isDirectory()`; if it fails it logs a warning, nulls the value, and (on Windows) falls back to the hardcoded `C:\Hyosung\MoniPlus2S`. + +## How the payload reaches the platform + +Transport is the shared `IncidentEventClient` (from `hiveops-agent-journal`) — the same client used by the event modules, but a **different method and endpoint**: + +``` +IncidentEventClient.syncConfig(AtmConfigSyncRequest req) + → POST {agent.endpoint | incident.endpoint}/api/atms/config/sync + Content-Type: application/json (Jackson-serialized AtmConfigSyncRequest) + → accepts HTTP 200 / 201 / 202; anything else → logErrorResponse + throw IOException +``` + +Note this is **not** `/api/journal-events` — config sync has its own `/api/atms/config/sync` endpoint. The backend keys off `atmName`/`country` in the body to find/update the device record. In production the base URL points at **agent-proxy** (`agent.endpoint`), which forwards to the device/incident backends; the legacy `incident.endpoint` path targets incident directly. + +## On-demand resync wiring + +`ConfigResyncRegistry` (in `hiveops-core`) is a static single-slot registry decoupling the `RESYNC_CONFIG` command handler from the resync implementation: + +``` +AtmConfigSyncModule.start() → ConfigResyncRegistry.register(this::syncAtmConfig) // sets one volatile callback +CommandCenterModule (RESYNC_CONFIG task) → ConfigResyncRegistry.trigger() → callback.run() +``` + +It holds exactly **one** `volatile Runnable` — last registrant wins. The registry's javadoc references `JournalEventModule` as the historical registrant, but in the current tree **`AtmConfigSyncModule` is the only caller of `register()`**, so it owns the `RESYNC_CONFIG` trigger. If another module ever also registers, whichever `start()`s last silently overwrites the other. If nothing registered (module disabled), `trigger()` logs a warning and does nothing. + +## Platform abstraction + +Uses the platform layer directly (unlike the file-tailing modules): + +- `context.getPlatformService().getConfigurationReader()` → `readSystemName / readCustomizingVersion / readHotfixVersion / readCountrySpecificVersion` (all `Optional`) and `readCassetteDenominations()`. +- `context.getPlatformService().isWindows()` → gates the `C:\Hyosung\MoniPlus2S` fallback. + +The `ConfigurationReader` abstracts Windows (registry) vs Linux (file/env) config sources, so the same module works cross-platform without OS branching in this class (aside from the Windows-only MoniPlus2S default). + +## Cross-links +- [technical.agent] — module system, lifecycle, `ModuleContext`, shared `IncidentEventClient` / HTTP settings, platform abstraction +- [platform.module-map] diff --git a/backend/src/main/resources/content/agent/atm-config-sync/claude.md b/backend/src/main/resources/content/agent/atm-config-sync/claude.md new file mode 100644 index 0000000..7f17f1c --- /dev/null +++ b/backend/src/main/resources/content/agent/atm-config-sync/claude.md @@ -0,0 +1,66 @@ +--- +module: agent.atm-config-sync +title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-atm-config-sync` source 2026-07-01. + +## Identity +- Maven artifact: **`hiveops-module-atm-config-sync`** (`com.hiveops`, version `1.0.0`, packaged as a standalone `jar`, parent `hiveops-agent-parent` `4.4.3`). +- Agent module id (`getName()`, and the string for **`modules.disabled`**): **`device-config-sync`** — NOT `atm-config-sync`. +- Entry class: `com.hiveops.configsync.AtmConfigSyncModule`, registered via `META-INF/services/com.hiveops.core.module.AgentModule`. +- Log package (`getLogPackage()`): `com.hiveops.configsync`. +- Dependencies (`getDependencies()`): none (empty list). +- Single class — no separate monitor/parser classes of its own. + +## Behavior (not an event/log-tail module) +- Emits **NO** journal/incident events. Sends one consolidated config payload. +- `isEnabled()` = `ready` = true only if `agent.endpoint` OR `incident.endpoint` resolved at `initialize()`. No `device.type` gate, no dedicated enable flag. +- Sync triggers: (1) once at `initialize()`; (2) every **6h** fixed-rate (`RESYNC_INTERVAL_HOURS=6`, initialDelay 0, daemon thread `config-resync`); (3) on-demand via **`RESYNC_CONFIG`** fleet command → `ConfigResyncRegistry.trigger()`. +- Each sync calls `ConnectionStateTracker.awaitReconnectJitter()` first (post-reconnect anti-stampede). +- Failure = logged (`ATM config sync failed: ...`), NOT retried/queued; next 6h tick or `RESYNC_CONFIG` recovers. + +## Config properties (exact keys) +| Key | Default | Notes | +|---|---|---| +| `agent.endpoint` | *(none)* | preferred base URL; if set → HTTP prefix `agent` | +| `incident.endpoint` | *(none)* | legacy fallback; used only if `agent.endpoint` unset → prefix `incident`. Neither set → module disabled | +| `incident.institution.key` | *(none)* | stamped on payload. Reads ONLY this key (not `agent.institution.key`) | +| `applog.events.dir` | *(none)* | dir for applog file; unset → Hyosung version + system info skipped | +| `applog.events.filename.format` | `APLog{YYYY}{MM}{DD}.log` | applog filename pattern | +| `moniplus2s.dir` | Win fallback `C:\Hyosung\MoniPlus2S` | if value isn't a real dir (backslash stripping) → warn + Windows default | + +Bearer token / HTTP params: shared `HttpClientHelper.loadSettingsFromProperties(props, prefix)` (`agent.auth.token` chain). No module-specific auth. + +## Transport (exact) +- Method: `IncidentEventClient.syncConfig(AtmConfigSyncRequest)`. +- Endpoint: **`POST {endpoint}/api/atms/config/sync`** (NOT `/api/journal-events`). +- Body: Jackson-serialized `AtmConfigSyncRequest` (`com.hiveops.events.dto`). +- Success: HTTP 200/201/202; else throws `IOException`. +- Backend resolves device by `atmName` + `country` in body. + +## Payload fields (`AtmConfigSyncRequest`) +- Identity: `atmName`, `country`, `agentVersion`, `timezone` (`TimeZone.getDefault().getID()`), `institutionKey`, `startupDetectionMethod` (from `startup-info.properties` → `startup.detection.method`). +- Versions (platform `ConfigurationReader`): `systemName`, `customizingVersion`, `hotfixVersion`, `countrySpecificVersion`. +- `cassettes`: `List{position, currency, denomination}` from `readCassetteDenominations()`; fallback = MoniPlus2S `HostNoteTypes` parse only if still empty. +- `hyosungVersion` (`HyosungVersionParser`): app/SDK/GSDK/Nextware/componentVersions. +- `systemInfo` (`AppLogHeaderParser`): OS + installed programs/packages. +- `moniplus2sConfig` + `moniplus2sLayeredConfig` (`MoniPlus2SConfigParser.parse()` / `.parseAllLayers()`). + +## Deployment +- Delivered via **`INSTALL_MODULE`** fleet task (drops JAR into agent `modules/` dir — `ProcessInstallModule` in `hiveops-file-collection`). +- Disable without uninstall: add `device-config-sync` to `modules.disabled`, push `CONFIG_UPDATE`. +- Force immediate resync: `RESYNC_CONFIG` fleet command (no restart). + +## Do NOT +- Do NOT use `atm-config-sync` as the `modules.disabled` value — the real id is **`device-config-sync`**. +- Do NOT look for emitted events — this module sends config, not `*_EVENT`s; there are no event-type strings. +- Do NOT expect it to POST to `/api/journal-events` — it uses `/api/atms/config/sync`. +- Do NOT expect `agent.institution.key` to be picked up for the payload — only `incident.institution.key` is read here. +- Do NOT put a single-backslash Windows path in `moniplus2s.dir` — properties strip it; double the backslashes or rely on the `C:\Hyosung\MoniPlus2S` default. +- Do NOT expect send failures to retry — they're logged and dropped; recovery is the next 6h tick / `RESYNC_CONFIG`. +- Do NOT assume `RESYNC_CONFIG` works if this module is disabled — it's the sole `ConfigResyncRegistry` registrant; disabled → `trigger()` no-ops with a warning. +- Do NOT expect a `device.type`/enable gate — the only gate is "an endpoint is configured." diff --git a/backend/src/main/resources/content/agent/atm-config-sync/internal.md b/backend/src/main/resources/content/agent/atm-config-sync/internal.md new file mode 100644 index 0000000..19ad909 --- /dev/null +++ b/backend/src/main/resources/content/agent/atm-config-sync/internal.md @@ -0,0 +1,70 @@ +--- +module: agent.atm-config-sync +title: ATM Config Sync — pushes ATM hardware/software/cassette config to the platform +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support.** Written 2026-07-01 from `hiveops-module-atm-config-sync` source. Verify against code before relying on specifics. + +## What this module is + +`hiveops-module-atm-config-sync` (Maven artifact `hiveops-module-atm-config-sync`, agent module id **`device-config-sync`**) is an **agent drop-in module**, not a backend service. It runs inside the HiveIQ Agent process and periodically **snapshots the ATM's configuration and inventory** and POSTs it to the HiveIQ backend so the device record (Devices / ATM detail) stays current: software versions, timezone, cassette denominations, and Hyosung MoniPlus2S settings. + +Unlike the event-reporting modules (journal-events, tcr-events), it emits **no journal/incident events**. It sends one consolidated **config-sync payload** (`AtmConfigSyncRequest`) to a dedicated endpoint. Think of it as "device metadata sync," not "alerting." + +## What it collects and syncs + +Each sync builds one `AtmConfigSyncRequest` from whatever sources resolve on that machine (all optional — missing sources are simply omitted): + +- **Identity / basics** — `atmName`, `country`, `agentVersion` (from the agent context), `timezone` (`TimeZone.getDefault().getID()`), and `institutionKey` (only from `incident.institution.key`). +- **Startup detection method** — read from `startup-info.properties` (`startup.detection.method`) in the agent's start dir, if present. +- **Software versions** — via the platform `ConfigurationReader`: `systemName`, `customizingVersion`, `hotfixVersion`, `countrySpecificVersion`. +- **Cassette denominations** — from `ConfigurationReader.readCassetteDenominations()`; if that yields nothing, it falls back to parsing cassettes out of the MoniPlus2S `HostNoteTypes`. +- **Hyosung version info** — parsed from the current applog file (`HyosungVersionParser`): application / SDK / GSDK / Nextware / component versions. +- **System info** — parsed from the applog header (`AppLogHeaderParser`): OS version, installed programs and packages. +- **MoniPlus2S config** — parsed from the MoniPlus2S directory (`MoniPlus2SConfigParser`): serial, model, host IP/port, plus a full **layered config** view (all config folders / XML files). + +Payload is POSTed to **`{endpoint}/api/atms/config/sync`** via `IncidentEventClient.syncConfig(...)`. The backend resolves the device by `atmName`/`country`. + +## When it syncs + +Three triggers, all running the same `syncAtmConfig()` method: + +1. **On agent startup** — `initialize()` runs one sync immediately (after the endpoint is resolved). +2. **On a fixed schedule** — a daemon scheduler (`config-resync` thread) re-syncs **every 6 hours** (`RESYNC_INTERVAL_HOURS = 6`), starting immediately at `start()`. +3. **On demand** — the module registers a callback with `ConfigResyncRegistry`, so a **`RESYNC_CONFIG`** fleet command (handled by the command processor) triggers an immediate re-sync without restarting the agent. + +Every sync first calls `ConnectionStateTracker.awaitReconnectJitter()` — after a reconnect it waits a randomized jitter so a fleet coming back online doesn't stampede the backend all at once. + +## Config properties it reads + +This module has **no dedicated `enabled` flag and no `device.type` gate** — it activates whenever an endpoint is configured. Keys read from `hiveops.properties`: + +| Property | Default | Meaning | +|---|---|---| +| `agent.endpoint` | *(none)* | Preferred backend base URL (agent-proxy). If set, HTTP settings use the `agent` prefix. | +| `incident.endpoint` | *(none)* | Legacy fallback base URL, used only if `agent.endpoint` is unset (`incident` prefix). If **both** are unset, the module returns early at init and stays disabled (`isEnabled()` → false). | +| `incident.institution.key` | *(none)* | Institution key stamped onto the sync payload. Note: this method reads **only** `incident.institution.key`, not `agent.institution.key`. | +| `applog.events.dir` | *(none)* | Directory holding the applog file used for Hyosung version + system info. If unset, version/system info are skipped. | +| `applog.events.filename.format` | `APLog{YYYY}{MM}{DD}.log` | Applog filename pattern used to resolve the current file. | +| `moniplus2s.dir` | Windows fallback `C:\Hyosung\MoniPlus2S` | MoniPlus2S config directory. If the property value doesn't resolve to a real directory (Java properties strip single backslashes, e.g. `C:\Hyosung` → `C:Hyosung`), it logs a warning and falls back to the hardcoded Windows path. | + +Bearer token and other HTTP params come from the shared agent HTTP settings (`HttpClientHelper.loadSettingsFromProperties(props, prefix)`) — the same `agent.auth.token` chain every module uses. No config-sync-specific auth. + +## How it's enabled / deployed + +- **No per-device gate** — as long as `agent.endpoint` (or legacy `incident.endpoint`) is set, the module runs on that ATM. It is safe on any device type; it only reports what it can read. +- Delivered like any other agent module: uploaded to the fleet artifact store and pushed via an **`INSTALL_MODULE`** fleet task, which drops the module JAR into the agent's `modules/` directory (handled by `ProcessInstallModule` in `hiveops-file-collection`). Standard rollout order applies: lab ATM → pilot device → fleet. +- Can be disabled fleet-wide without removing the JAR by adding **`device-config-sync`** to the comma-separated `modules.disabled` property and pushing a `CONFIG_UPDATE`. (Disable string is `device-config-sync` — the `getName()` value — **not** `atm-config-sync`.) +- Force a fresh push at any time with a **`RESYNC_CONFIG`** fleet command — no restart needed. + +## Common failure modes / what to check + +- **Device metadata never populates for an ATM** — confirm `agent.endpoint`/`incident.endpoint` is set; with neither, the module is inert at startup (`isEnabled()` false). Also check the agent log for `ATM config sync complete` vs `ATM config sync failed: ...`. +- **Sync runs but versions/system info are blank** — usually `applog.events.dir` is unset or points at a directory with no matching `APLog…` file; the Hyosung/system-info blocks are simply skipped. Cassette/MoniPlus2S data can still be present. +- **MoniPlus2S config missing on a Hyosung box** — check the `moniplus2s.dir` warning in the log (`… not found (backslash escaping issue?)`). A single-backslash path in `hiveops.properties` gets mangled; either double the backslashes or rely on the `C:\Hyosung\MoniPlus2S` default. +- **Cassettes wrong/empty** — the platform `ConfigurationReader` is tried first; the MoniPlus2S `HostNoteTypes` fallback only fires when the reader returns none. If both are empty, no cassettes are sent. +- **Whole sync fails** — `syncAtmConfig()` wraps everything in a try/catch and logs `ATM config sync failed: `; a failed HTTP call (`syncConfig` throws on non-2xx) is logged but **not** queued/retried — the next scheduled 6h tick (or a `RESYNC_CONFIG`) is the recovery path. +- **On-demand resync does nothing** — a `RESYNC_CONFIG` only fires if this module registered its callback (i.e. it's enabled and started). If it's in `modules.disabled`, the command center logs "no callback registered." diff --git a/backend/src/main/resources/content/agent/ejournal-db-monitor/architect.md b/backend/src/main/resources/content/agent/ejournal-db-monitor/architect.md new file mode 100644 index 0000000..f83564d --- /dev/null +++ b/backend/src/main/resources/content/agent/ejournal-db-monitor/architect.md @@ -0,0 +1,101 @@ +--- +module: agent.ejournal-db-monitor +title: EJournal DB Monitor — how it's built +tab: Architect +order: 30 +audience: bcos +--- + +# EJournal DB Monitor (Architecture) + +A single-class agent module, `com.hiveops.ejournal.EJournalDbMonitorModule`, +registered as an SPI service via +`META-INF/services/com.hiveops.core.module.AgentModule`. It plugs into the standard +agent module lifecycle (`initialize` → `isEnabled` → `start` → `stop`) — see +[technical.agent] for the module system as a whole. + +## Module shape + +- **Artifact:** `hiveops-module-ejournal-db-monitor.jar` (Maven `finalName`), module + version `1.0.0`, parent `hiveops-agent-parent` `4.4.3`. +- **Dependencies** (all `provided` — supplied by the fat JAR at runtime, not shaded + in): `hiveops-core` (module API), `hiveops-agent-journal` (`EventType`, + `IncidentEventClient`, `CreateJournalEventRequest`, `HttpClientHelper`), + `log4j-api`, `commons-lang3`. +- `getName()` → `ejournal-db-monitor`; `getDependencies()` → empty (no ordering + constraints); `getLogPackage()` → `com.hiveops.ejournal`. + +## Flow: poll → detect → emit + +The module is a **poller**, not a log-tail or file-watch. There is no hook into the +ATM app; it just stats a file on a schedule. + +1. **`initialize(ModuleContext)`** + - Reads `ejournal.db.monitor.enabled`; returns early if disabled. + - Resolves the event endpoint: `agent.endpoint` preferred, else + `incident.endpoint`. If neither is set, returns early (module stays not-ready → + no-op). HTTP config loaded via `HttpClientHelper.loadSettingsFromProperties` + with prefix `agent` or `incident` accordingly. + - Builds an `IncidentEventClient` from those settings plus + `context.getAtmName()` and `context.getCountry()`. + - Parses thresholds/interval/cooldown into bytes/minutes/hours and sets + `ready = true`. +2. **`isEnabled(ModuleContext)`** → returns the `ready` flag set in `initialize`. +3. **`start()`** — creates a single-thread `ScheduledExecutorService` (daemon thread + named `ejournal-db-monitor`) and schedules `checkDatabaseSize()` at fixed rate, + starting immediately (`initialDelay = 0`), every `intervalMinutes`. +4. **`checkDatabaseSize()`** (each tick): + - `resolveDbFile()` — if `ejournal.db.monitor.filename` set, use it; otherwise + list the directory filtered to `.mdb/.sdf/.db/.sqlite` and pick the **largest**. + - `classify(sizeBytes)` → `NONE` / `WARNING` / `CRITICAL` against the byte + thresholds. + - State reset: if now `NONE` but previously alerted, log and clear + `lastAlertLevel`/`lastAlertTime` (DB was cleared). + - Suppression: send only if the level **escalated** (`ordinal()` increased) OR the + cooldown (`cooldownHours`) has elapsed since `lastAlertTime`. + - On send, record `lastAlertLevel` + `lastAlertTime`. + - The whole body is wrapped in try/catch so a bad tick logs and the schedule + continues. +5. **`sendAlert(level, file, sizeBytes)`** — maps level to `EventType` + (`EJOURNAL_DB_CRITICAL` or `EJOURNAL_DB_WARNING`), formats a human `eventDetails` + string (name, MB, % of 2 GB), and builds a `CreateJournalEventRequest`. +6. **`stop()`** — `scheduler.shutdownNow()`. + +Alert state (`lastAlertLevel`, `lastAlertTime`, the `AlertLevel` enum +`NONE/WARNING/CRITICAL`) is **in-memory only** — it resets on agent restart, so the +first post-restart check that is over-threshold will alert again. + +## How events reach the platform + +The module reuses the shared journal-events pipeline rather than any bespoke +transport: + +``` +EJournalDbMonitorModule.sendAlert() + → CreateJournalEventRequest.builder() + .agentAtmId(atmName) + .eventType(EJOURNAL_DB_WARNING | EJOURNAL_DB_CRITICAL) + .eventDetails(...) + .eventSource("HIVEOPS_AGENT_EJDB") + .build() + → IncidentEventClient.sendEvent(event) + → POST {endpoint}/api/journal-events (JSON) +``` + +`endpoint` is `agent.endpoint` (agent-proxy) when configured, else +`incident.endpoint` (direct to hiveops-incident). The ATM is resolved server-side by +`agentAtmId`; the module does not resolve a numeric `atmId` itself. From there the +event follows the normal HiveIQ incident path (auto-register + incident creation). + +## Platform abstraction + +The module itself uses **no** `PlatformService` — it only does `java.io.File` +size/listing, and the default directory is Windows-shaped (`D:\EJOURNAL`), matching +its Hyosung target. Platform-specific pieces (delivery to `modules/`, agent restart) +live in the `INSTALL_MODULE` handler (`ProcessInstallModule`), which does use +`PlatformService.getSystemCommands()` / `getPathResolver()`. For non-Windows or +non-standard installs, point `ejournal.db.monitor.dir` at the real path. + +Cross-reference: [technical.agent] for the ServiceLoader module registry, lifecycle, +and the `IncidentEventClient` / `/api/journal-events` contract shared with the +`journal-events` module. diff --git a/backend/src/main/resources/content/agent/ejournal-db-monitor/claude.md b/backend/src/main/resources/content/agent/ejournal-db-monitor/claude.md new file mode 100644 index 0000000..db79476 --- /dev/null +++ b/backend/src/main/resources/content/agent/ejournal-db-monitor/claude.md @@ -0,0 +1,82 @@ +--- +module: agent.ejournal-db-monitor +title: EJournal DB Monitor — act-without-guessing reference +tab: Claude +order: 40 +audience: bcos +--- + +# EJournal DB Monitor (Claude quick-reference) + +## Identity + +- Module name (`getName()`): `ejournal-db-monitor` +- Entry class: `com.hiveops.ejournal.EJournalDbMonitorModule` +- Maven artifact / JAR: `hiveops-module-ejournal-db-monitor.jar` (module version `1.0.0`) +- Log package / thread name: `com.hiveops.ejournal` / `ejournal-db-monitor` +- Dependencies: none (`getDependencies()` empty) +- Purpose: poll Hyosung EJournal DB file size, alert before the **2 GB** write limit + +## Config property keys (exact) — defaults from code + +| Key | Default | +|-----|---------| +| `ejournal.db.monitor.enabled` | `true` | +| `ejournal.db.monitor.dir` | `D:\EJOURNAL` | +| `ejournal.db.monitor.filename` | *(blank → auto-pick largest DB file)* | +| `ejournal.db.monitor.warn.mb` | `1536` | +| `ejournal.db.monitor.critical.mb` | `2048` | +| `ejournal.db.monitor.interval.min` | `15` | +| `ejournal.db.monitor.cooldown.hours` | `24` | + +Endpoint resolution (not module-specific keys, but required): uses `agent.endpoint` +if set, else `incident.endpoint`. Neither set → module is a silent no-op. + +## Event types emitted (exact strings) + +- `EJOURNAL_DB_WARNING` — size ≥ `warn.mb` (default 1.5 GB) +- `EJOURNAL_DB_CRITICAL` — size ≥ `critical.mb` (default 2.0 GB) + +Both defined in `com.hiveops.events.EventType`. Sent via +`IncidentEventClient.sendEvent()` → `POST {endpoint}/api/journal-events`. + +## Event payload fields + +`CreateJournalEventRequest` with: `agentAtmId` = ATM name, `eventType` = one of the +two above (`.name()`), `eventDetails` = formatted string (filename, MB, % of 2 GB), +`eventSource` = `HIVEOPS_AGENT_EJDB` (constant — use to filter these events). + +## Auto-detect file rule + +If `ejournal.db.monitor.filename` is blank, picks the **largest** file in +`ejournal.db.monitor.dir` matching extensions: `.mdb`, `.sdf`, `.db`, `.sqlite`. + +## Alert de-dup logic + +- Fire only if level escalated (Warning→Critical) OR cooldown elapsed. +- Size dropping below warning threshold resets state (treated as DB cleared). +- State is in-memory only → resets on agent restart. + +## Deploy / disable + +- Deploy: fleet `INSTALL_MODULE` task (artifact `task.paths` or legacy + `configPatch.url`+`filename`, must end `.jar`) → lands in agent `modules/` → + agent restarts. Handler: `ProcessInstallModule`. +- Disable: `ejournal.db.monitor.enabled=false` OR add `ejournal-db-monitor` to + `modules.disabled` (push via `CONFIG_UPDATE`). + +## Do NOT + +- Do NOT assume events go to a dedicated endpoint — they use the shared + `/api/journal-events`, same as the `journal-events` module. +- Do NOT expect a numeric `atmId` from the module — it sends `agentAtmId` (name); + the server resolves the ATM. +- Do NOT expect persisted alert state across restarts — it is in-memory only. +- Do NOT expect Linux path defaults — default dir is `D:\EJOURNAL` (Hyosung/Windows); + set `ejournal.db.monitor.dir` for other layouts. +- Do NOT expect alert spam — one alert per level per cooldown (default 24 h) unless + escalating. +- Do NOT rely on it detecting DBs with extensions outside + `.mdb/.sdf/.db/.sqlite` unless you pin `ejournal.db.monitor.filename`. +- Do NOT count on any `PlatformService` use inside the module — it uses plain + `java.io.File` only. diff --git a/backend/src/main/resources/content/agent/ejournal-db-monitor/internal.md b/backend/src/main/resources/content/agent/ejournal-db-monitor/internal.md new file mode 100644 index 0000000..140722b --- /dev/null +++ b/backend/src/main/resources/content/agent/ejournal-db-monitor/internal.md @@ -0,0 +1,109 @@ +--- +module: agent.ejournal-db-monitor +title: EJournal DB Monitor — guards the Hyosung 2 GB journal write limit +tab: Internal +order: 20 +audience: dev +--- + +# EJournal DB Monitor (Internal / Ops view) + +## What it monitors and why + +Hyosung MoniPlus2S ATMs store their electronic journal in a local database file +(Access `.mdb`, SQL Compact `.sdf`, or a plain `.db`/`.sqlite`). That engine has a +hard **2 GB** file-size limit — once the file reaches it, the ATM application +**stops writing journal data** and journal capture silently fails. There is no +customer-visible alarm on the machine itself. + +The `ejournal-db-monitor` agent module watches that database file size on a timer +and raises HiveIQ incident events **before** the ATM hits the wall, so helpdesk can +schedule the journal-clear/maintenance while the ATM is still capturing. + +Two thresholds (both grounded in `EJournalDbMonitorModule`): + +| Level | Default trigger | Event emitted | Meaning | +|----------|-----------------|-------------------------|---------| +| Warning | 1.5 GB (1536 MB)| `EJOURNAL_DB_WARNING` | Early notice — plan maintenance | +| Critical | 2.0 GB (2048 MB)| `EJOURNAL_DB_CRITICAL` | At/near the limit — journal writing may stop, act immediately | + +The event `eventDetails` string reports the file name, current size in MB, and the +percentage of the 2 GB limit consumed. Events are tagged with `eventSource = +HIVEOPS_AGENT_EJDB` so they can be told apart from normal journal-parsed events. + +### Alert de-duplication + +Once an alert level fires it is **not** repeated until one of: + +- The level **escalates** (Warning → Critical), or +- The **cooldown** window elapses (default 24 h), or +- The file drops back **below** the warning threshold (i.e. the DB was cleared) — + this resets the alert state so the next growth cycle alerts fresh. + +So a stuck-full ATM produces at most one Critical event per cooldown window, not a +flood. + +## Config properties it registers + +All read from the agent's main properties (`hiveops.properties`). Keys and defaults +come straight from `EJournalDbMonitorModule.initialize()`: + +| Property | Default | Purpose | +|----------|---------|---------| +| `ejournal.db.monitor.enabled` | `true` | Master on/off. If `false`, module no-ops (never becomes ready). | +| `ejournal.db.monitor.dir` | `D:\EJOURNAL` | Directory to scan. | +| `ejournal.db.monitor.filename` | *(blank)* | Specific file to watch. If blank, auto-detects the **largest** file matching `.mdb` / `.sdf` / `.db` / `.sqlite`. | +| `ejournal.db.monitor.warn.mb` | `1536` | Warning threshold (MB). | +| `ejournal.db.monitor.critical.mb` | `2048` | Critical threshold (MB). | +| `ejournal.db.monitor.interval.min` | `15` | Check interval (minutes). First check runs immediately at start. | +| `ejournal.db.monitor.cooldown.hours` | `24` | Hours before re-alerting at the same level. | + +The module also needs an event endpoint — it uses `agent.endpoint` if present +(preferred, agent-proxy), else falls back to `incident.endpoint`. HTTP settings are +loaded with the matching prefix (`agent.*` or `incident.*`). If **neither** endpoint +is set the module initializes to a no-op and never sends events. + +## How it's enabled / deployed to ATMs + +This is a **standalone drop-in module** (`pom.xml` `finalName = +hiveops-module-ejournal-db-monitor`, artifact `hiveops-module-ejournal-db-monitor.jar`, +module version `1.0.0`). It is not bundled in the fat JAR by default; it is discovered +at runtime via `ServiceLoader` from the agent's `modules/` directory. + +Two ways to get it onto an ATM: + +1. **Fleet `INSTALL_MODULE` task** (preferred) — delivers the module JAR to the + ATM's `modules/` directory, then the agent restarts to pick it up. Supports both + artifact-store delivery (chunked download, `task.paths`) and legacy URL delivery + (`configPatch.url` + `filename`, must end in `.jar`). See + `ProcessInstallModule` in `hiveops-file-collection`. +2. **Manual drop-in** — copy `hiveops-module-ejournal-db-monitor.jar` into the + agent install's `modules/` folder and restart the agent. + +To turn it off without removing the JAR: either set +`ejournal.db.monitor.enabled=false`, or add `ejournal-db-monitor` to the +`modules.disabled` property in `hiveops.properties`. Both are pushable via a +`CONFIG_UPDATE` fleet task. + +## Common failure modes / what to check + +- **No events ever arrive** — check that `agent.endpoint` (or `incident.endpoint`) + is set; with neither, the module silently becomes a no-op. Also confirm the module + is present in `modules/` and not listed in `modules.disabled`. +- **"No EJournal database file found"** (debug log) — `ejournal.db.monitor.dir` + wrong for this box, or the DB uses an extension outside `.mdb/.sdf/.db/.sqlite`. + Set `ejournal.db.monitor.filename` explicitly. +- **Wrong file watched** — with no `filename` set, the module picks the *largest* + matching file in the directory. If backups or unrelated DBs share the dir, pin the + exact filename. +- **Expected an alert but none came** — the level may be inside its cooldown (24 h + default) and not yet escalated. Escalation Warning→Critical always fires + immediately regardless of cooldown. +- **Alerts stopped after a clear** — expected: dropping below the warning threshold + resets alert state; growth back up re-alerts. +- **Send failures** — `Failed to send EJournal DB ... event` in the log means the + size check ran but the HTTP POST to `/api/journal-events` failed; check agent-proxy + reachability. The check will retry on the next interval. + +Log package for this module is `com.hiveops.ejournal` (thread name +`ejournal-db-monitor`). diff --git a/backend/src/main/resources/content/agent/hw-inventory/architect.md b/backend/src/main/resources/content/agent/hw-inventory/architect.md new file mode 100644 index 0000000..05a3eda --- /dev/null +++ b/backend/src/main/resources/content/agent/hw-inventory/architect.md @@ -0,0 +1,78 @@ +--- +module: agent.hw-inventory +title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · architecture view.** Confirmed against `hiveops-module-hw-inventory` source 2026-07-01. See [technical.agent] for the module system as a whole. + +## Shape + +A tiny 4-type module: one `AgentModule` entry point, a collector SPI interface, and two platform collectors. + +| Class | Role | +|---|---| +| `HardwareInventoryModule` | `AgentModule` entry point — lifecycle, scheduling, CDN probe, POST orchestration | +| `HardwareCollector` (interface) | `collect(atmName, country, institutionKey) → HardwareInventoryRequest` | +| `LinuxHardwareCollector` | Reads `/proc`, `/sys`, `/etc/os-release`, `lsblk`, Java `File.listRoots()` | +| `WindowsHardwareCollector` | Shells out to **PowerShell** (`Get-CimInstance`, `Get-HotFix`, registry reads) | + +The transport (`IncidentEventClient.syncHardware`) and the payload DTO (`HardwareInventoryRequest`) live in the shared `hiveops-agent-journal` module (`com.hiveops.events`), reused here as a `provided` dependency. + +## Lifecycle & flow + +Standard SPI lifecycle (`initialize → isEnabled → start → stop`), driven by `ModuleRegistry`: + +1. **`initialize(ModuleContext)`** + - Reads `hw.inventory.enabled`; if `false`, returns early (module never activates). + - Resolves the base URL: prefers `agent.endpoint`, falls back to `incident.endpoint`. If neither is set → inactive. + - Loads HTTP settings via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` = `"agent"` or `"incident"` to match the chosen endpoint. + - Pulls identity from context: `getAtmName()`, `getCountry()`, and `incident.institution.key` from props. + - Picks the collector from `context.getPlatformService().isWindows()` → `WindowsHardwareCollector` or `LinuxHardwareCollector`. + - Sets `ready = true` and **calls `syncInventory()` once immediately** (a first sync happens during init, before `start()`). +2. **`isEnabled(context)`** → returns the `ready` flag. +3. **`start()`** — creates a single daemon `ScheduledExecutorService` (thread name `hw-inventory`) and `scheduleAtFixedRate(this::syncInventory, 0, intervalHours, HOURS)`. Because initial delay is `0`, this fires an **additional** sync right at startup (so a fresh agent syncs twice close together — once from `initialize`, once from the delay-0 schedule). +4. **`stop()`** — `scheduler.shutdownNow()`. + +Each `syncInventory()`: + +``` +collector.collect(atmName, country, institutionKey) // build HardwareInventoryRequest + → req.setCdnDownloadSpeedBps(probeCdnDownload()) // measure CDN bandwidth + → client.syncHardware(req) // POST /api/atms/hardware/sync +``` + +The whole method is wrapped in try/catch; a failure logs `sync failed` and is swallowed so the schedule keeps running. Inside each collector, every sub-step (CPU, system, disks, drives, NICs, BIOS/boot, patches, …) is independently try/caught — a partial snapshot is still sent. + +## How data reaches the platform + +`IncidentEventClient.syncHardware(req)` does a plain `HttpURLConnection` **POST** of the JSON `HardwareInventoryRequest` to: + +``` +{endpoint}/api/atms/hardware/sync +``` + +with `Content-Type: application/json` and the shared HTTP settings (auth header, host header) applied by `HttpClientHelper`. `{endpoint}` is the agent-proxy base (`agent.endpoint`) on current ATMs, so the request lands on **agent-proxy** and is forwarded to the incident/devices hardware-sync handler — the same agent→agent-proxy path every other agent call uses. Accepts `200/201/202`; anything else throws `IOException`. + +The ATM is identified by `atmName` + `country` in the payload (plus `institutionKey`); there is no client-side `atmId` resolution here — the platform maps it server-side. + +## CDN bandwidth probe (`probeCdnDownload()`) + +Runs inside the same sync, purely to populate `cdnDownloadSpeedBps`: + +1. GET the manifest at `agent.update.check.url` (default `https://cdn.bcos.cloud/downloads/agent/latest.json`), sending `X-Institution-Key` if an institution key is set. Parsed leniently (`FAIL_ON_UNKNOWN_PROPERTIES=false`). +2. Resolve `patches.windows` or `patches.linux` from the manifest (chosen by `isWindows`); relative filenames are resolved against the manifest base URL. +3. HTTP **Range** request `bytes=0-524287` (first 512 KB) of that patch file; accepts `206 Partial` or `200`. +4. Read the bytes, time the transfer, compute `rawBps = bytes*1000/elapsedMs`, then `speedBps = rawBps * task.hotfix.network.cap` (cap clamped `0.01`–`1.0`, default `0.9`). +5. Any failure (bad HTTP, no patch URL, exception) returns `0`. + +## Platform abstraction + +The module uses `PlatformService` **only** for OS detection (`isWindows()`) to select the collector. The collectors themselves do **not** go through the platform abstraction — they read OS sources directly: + +- **Linux:** `/proc/cpuinfo`, `/sys/devices/.../cpufreq`, `/sys/class/dmi/id/*`, `lsblk -b -o … -n -d`, `/sys/block/*/queue/rotational` (SSD/HDD), `/sys/class/net/*` (physical-NIC filter via a `device` symlink), `/proc/net/dev` (throughput), `/proc/stat` `btime` (boot time), `/etc/os-release`, `File.listRoots()` (drives). +- **Windows:** PowerShell (`-NoProfile -NonInteractive -ExecutionPolicy Bypass`) with `Get-CimInstance` (`Win32_Processor`, `Win32_ComputerSystem`, `Win32_DiskDrive`, `Win32_LogicalDisk`, `Win32_NetworkAdapter`, `Win32_BIOS`, `Win32_OperatingSystem`), `Get-NetAdapterStatistics` (throughput), `Get-HotFix` (patches), registry `Uninstall` keys (programs), and `USBSTOR`/`Control Panel\Desktop` registry reads (security posture). stderr is drained on a background thread so it can't block stdout. + +> Note: the pom `` says Windows uses `wmic` — that's stale; the code uses PowerShell/CIM. diff --git a/backend/src/main/resources/content/agent/hw-inventory/claude.md b/backend/src/main/resources/content/agent/hw-inventory/claude.md new file mode 100644 index 0000000..fe22759 --- /dev/null +++ b/backend/src/main/resources/content/agent/hw-inventory/claude.md @@ -0,0 +1,67 @@ +--- +module: agent.hw-inventory +title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-hw-inventory` source 2026-07-01. + +## Identity +- Module name (`AgentModule.getName()`): **`hw-inventory`** +- Maven artifact: **`hiveops-module-hw-inventory`** (own version `1.0.0`; parent `hiveops-agent-parent` currently `4.4.3`) +- Entry class: `com.hiveops.hwinventory.HardwareInventoryModule` (SPI file: `META-INF/services/com.hiveops.core.module.AgentModule`) +- Other classes: `HardwareCollector` (interface), `LinuxHardwareCollector`, `WindowsHardwareCollector` +- Log package (`getLogPackage()`): `com.hiveops.hwinventory` (→ per-module log, convention `logs/modules/hw-inventory.log` — path unverified) +- Dependencies (`getDependencies()`): **none** (empty list) +- Drop-in JAR: `modules/hiveops-module-hw-inventory.jar`; in **both** build-dist module sets (`atm` default **and** `srv`) + +## Config properties +| Key | Default | Notes | +|---|---|---| +| `hw.inventory.enabled` | `true` | `false` → module inactive | +| `hw.inventory.sync.interval.hours` | `24` | fixed-rate resync period (hours) | + +Reused (not owned): +- `agent.endpoint` (preferred) **or** `incident.endpoint` (fallback) — HTTP base URL; **neither set → inactive** +- `incident.institution.key` — institution key; sent in payload + as `X-Institution-Key` on the CDN probe +- `agent.update.check.url` — CDN manifest for probe; default `https://cdn.bcos.cloud/downloads/agent/latest.json` +- `task.hotfix.network.cap` — probe speed multiplier; default `0.9`, clamped `0.01`–`1.0` +- `modules.disabled` — comma list; include `hw-inventory` to disable +- auth token / host / timeouts via `HttpClientHelper.loadSettingsFromProperties(props, "agent"|"incident")` + +## Events / status strings emitted +- **None.** No `EventType`, no `IAT_*`, no journal/incident events, no fleet task status. This module does **not** call the journal-event path. +- Sole output = one JSON POST of `HardwareInventoryRequest`. + +## HTTP endpoint used (agent → agent-proxy → incident) +| Call | Method + Path | +|---|---| +| Inventory sync | `POST {endpoint}/api/atms/hardware/sync` (`IncidentEventClient.syncHardware`) | +| CDN probe (manifest) | `GET {agent.update.check.url}` | +| CDN probe (speed test) | `GET ` with header `Range: bytes=0-524287` | + +Accepts `200/201/202` on sync; else throws `IOException`. + +## Payload fields (`HardwareInventoryRequest`) +- Identity: `atmName`, `country`, `institutionKey` +- Both OS: `cpu{name,cores,maxClockMhz}`, `system{manufacturer,model,type}`, `disks[]{model,serialNumber,sizeBytes,mediaType}`, `drives[]{letter,fileSystem,totalBytes,freeBytes}`, `nics[]{name,macAddress,speedBps,rxBytesPerSec,txBytesPerSec}`, `biosSerial`, `lastBootTime`, `osVersion`, `cdnDownloadSpeedBps` +- Windows-only: `osInstallDate`, `installedPatches[]`, `installedPatchDates{}`, `installedPrograms[]{name,version,installedDate}`, `usbLockStatus`, `windowsLockStatus` + +## Gotchas +- **Syncs twice at startup:** once in `initialize()`, then again from `start()`'s `scheduleAtFixedRate(..., 0, ...)` (initial delay 0). +- **~5s added per sync:** NIC RX/TX throughput is measured by sampling twice, 5s apart (`Thread.sleep(5000)`). +- **Partial payloads are normal:** every collector step is independently try/caught; failures are logged and the rest is still sent. +- **CDN probe never blocks the sync:** on any failure it returns `0` for `cdnDownloadSpeedBps`; inventory still POSTs. +- **Windows path = PowerShell/CIM**, not `wmic` (pom `` is stale on this). +- **Server (`srv`) devices also run this module** — it's not ATM-only. +- Linux physical-NIC filter: skips `lo`, virtual ifaces (no `/sys/class/net//device` symlink), and MACs starting `00:00:00`. +- No client-side `atmId` resolution — platform maps `atmName`+`country` server-side. + +## Do NOT +- Do **not** expect `IAT_*`/journal events from this module — it emits none. +- Do **not** look for a poll loop / fleet-task handling — it doesn't poll; it's push-only on a timer. +- Do **not** add a `hw-inventory.*`-namespaced key expecting it to work; only `hw.inventory.enabled` and `hw.inventory.sync.interval.hours` exist. +- Do **not** assume the collectors go through `PlatformService` — only `isWindows()` does; collection reads OS sources directly. +- Do **not** point this at incident directly on modern ATMs — route via `agent.endpoint` (agent-proxy). diff --git a/backend/src/main/resources/content/agent/hw-inventory/internal.md b/backend/src/main/resources/content/agent/hw-inventory/internal.md new file mode 100644 index 0000000..f432e40 --- /dev/null +++ b/backend/src/main/resources/content/agent/hw-inventory/internal.md @@ -0,0 +1,66 @@ +--- +module: agent.hw-inventory +title: Hardware Inventory — ATM hardware/OS inventory sync + CDN bandwidth probe +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support view.** Confirmed against `hiveops-module-hw-inventory` source 2026-07-01. + +## What it does (and doesn't) + +`hiveops-module-hw-inventory` (module name `hw-inventory`, entry class `HardwareInventoryModule`) is a **periodic push module**, not an event/incident monitor. Once per run it takes a full snapshot of the ATM's hardware and OS and **POSTs it to the platform** at `/api/atms/hardware/sync` (via `IncidentEventClient.syncHardware`). It does **not** tail journals, does **not** emit `IAT_*`/journal-incident events, and does **not** poll for fleet tasks. It is fire-and-forget inventory reporting. + +It runs the collection **once on startup** and then **re-syncs on a fixed interval** (default every 24h). + +What each sync collects: + +| Area | Both platforms | Windows-only extras | +|---|---|---| +| CPU | model name, cores, max clock (MHz) | — | +| System board | manufacturer, model, type | — | +| Physical disks | model, serial, size, media type (SSD/HDD) | — | +| Logical drives | mount/letter, filesystem, total/free bytes | — | +| NICs | name, MAC, link speed, **measured RX/TX throughput** (sampled over 5s) | — | +| BIOS / boot | BIOS/product serial, last boot time | OS install date | +| OS | OS version string | installed patches (KB IDs + dates), installed programs, USB-lock status, Windows screen-lock status | +| CDN | effective CDN download speed (bytes/s) — see below | — | + +**CDN bandwidth probe.** On every sync the module also measures how fast this ATM can pull from the CDN: it fetches the agent manifest (`latest.json`), resolves the platform patch file URL, issues a **512 KB HTTP Range request**, times it, multiplies the raw rate by a configurable cap, and ships the result as `cdnDownloadSpeedBps` on the same payload. The platform uses this so hotfix/software delivery can be paced per-device to the ATM's real link speed. A failed probe just sends `0` — the inventory sync still succeeds. + +## Config properties it registers + +| Key | Default | Meaning | +|---|---|---| +| `hw.inventory.enabled` | `true` | Master on/off. `false` → module logs "disabled" and stays inactive. | +| `hw.inventory.sync.interval.hours` | `24` | Hours between re-syncs (also the fixed-rate schedule period). | + +Properties it **reuses** (does not own): + +| Key | Used for | +|---|---| +| `agent.endpoint` | Preferred HTTP base URL (agent-proxy). If set, module uses the `agent` client prefix. | +| `incident.endpoint` | Fallback base URL if `agent.endpoint` is absent (older ATMs). If neither is set, the module goes inactive. | +| `incident.institution.key` | Institution key; sent as `X-Institution-Key` header on the CDN probe and included in the inventory payload. | +| `agent.update.check.url` | CDN manifest URL for the probe. Default `https://cdn.bcos.cloud/downloads/agent/latest.json`. | +| `task.hotfix.network.cap` | Multiplier applied to the measured raw CDN speed (default `0.9`, clamped to `0.01`–`1.0`). Same knob the hotfix delivery path uses. | +| `agent.*` / `incident.*` HTTP settings | Auth token, host header, timeouts — loaded via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` under whichever prefix (`agent` or `incident`) is active. | + +## Enabled / deployed to ATMs + +- **Enable condition:** on by default. It self-activates whenever `hw.inventory.enabled` is not `false` **and** an `agent.endpoint` or `incident.endpoint` is configured. To disable on a specific ATM/institution, push `hw.inventory.enabled=false` (or add `hw-inventory` to `modules.disabled`) via a `CONFIG_UPDATE` fleet task. +- **Shipped how:** it is a drop-in JAR (`modules/hiveops-module-hw-inventory.jar`), discovered via ServiceLoader — **not** baked into the fat JAR. `deployment/build-dist.sh` includes it in **both** module sets: the default `atm` set and the `srv` (server-monitoring) set. So every standard install/patch ZIP carries it. +- **Retrofitting an ATM that doesn't have it yet:** deliver via a fleet `INSTALL_MODULE` task (drops the JAR into `modules/` and restarts the agent), or via a full agent update that includes the current module set. + +## Common failure modes / what to check + +| Symptom | Likely cause | What to check | +|---|---|---| +| No hardware ever appears for an ATM | Module inactive — no endpoint configured, or `hw.inventory.enabled=false`, or JAR missing | Confirm `agent.endpoint`/`incident.endpoint` is set; grep the module log for `hw-inventory initialized`; confirm `modules/hiveops-module-hw-inventory.jar` is present | +| Sync logs `Failed to sync hardware inventory: HTTP ` | agent-proxy/incident rejected the POST (auth, routing, or `/api/atms/hardware/sync` not reachable) | Verify the endpoint resolves through agent-proxy and the agent's auth token/institution key are valid | +| Windows fields (patches, programs, lock status) are empty but Linux-style fields present | Wrong collector, or PowerShell failing | These come from PowerShell (`Get-CimInstance`, `Get-HotFix`, registry reads); check the module log for `PowerShell exited ` warnings | +| CPU/disk/NIC fields blank | A single collector step failed but sync still ran | Each collector step is independently try/caught and logs `failed to collect `; a partial payload is still sent | +| `cdnDownloadSpeedBps` is 0 | CDN probe failed or manifest had no patch URL | Non-fatal; check log for `CDN probe returned HTTP ` / `CDN probe failed`; verify the ATM can reach `cdn.bcos.cloud` and the manifest has `patches.windows`/`patches.linux` | +| Inventory looks ~24h stale | That's the default resync cadence | Lower `hw.inventory.sync.interval.hours` via `CONFIG_UPDATE`, or restart the agent to force an immediate sync | +| Each NIC sync adds ~5s of latency | Expected — throughput is measured by sampling `/proc/net/dev` (Linux) / `Get-NetAdapterStatistics` (Windows) twice, 5s apart | Not a bug; it's the RX/TX measurement window | diff --git a/backend/src/main/resources/content/agent/log-collect/architect.md b/backend/src/main/resources/content/agent/log-collect/architect.md new file mode 100644 index 0000000..107b211 --- /dev/null +++ b/backend/src/main/resources/content/agent/log-collect/architect.md @@ -0,0 +1,84 @@ +--- +module: agent.log-collect +title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · how it's built.** Confirmed against `hiveops-module-log-collect` source (`com.hiveops.logcollect`) 2026-07-01. See [technical.agent] for agent-wide context (module system, HTTP layer, platform abstraction). + +## Maven module + +`hiveops-module-log-collect` (artifact `hiveops-module-log-collect`, own version `1.0.0`, parented by `hiveops-agent-parent` — currently `4.4.3`). Packaged as a standalone jar (`${project.artifactId}` → `hiveops-module-log-collect.jar`). + +Dependencies (all `provided` scope — supplied by the agent runtime, not shaded in): + +- `hiveops-core` — module SPI + HTTP layer (`FileUploadClient`, `HttpClientHelper`, `HttpClientSettings`, `CurrentAtmPendingTask`). +- `hiveops-agent-journal` (`1.0.0`) — for `com.hiveops.logs.AgentLogUploader`, which does the actual ZIP-and-upload. +- `log4j-api`, `commons-lang3`. + +Registered via ServiceLoader SPI: `src/main/resources/META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.logcollect.LogCollectPathModule`. + +## The one class + +There is a single class, `LogCollectPathModule`, implementing `AgentModule`. No sub-packages, no monitors, no emitters — the heavy lifting lives in `AgentLogUploader` (from `hiveops-agent-journal`). + +### Lifecycle (`AgentModule` contract) + +- `getName()` → **`log-collect-path`** +- `getVersion()` → `1.0.0` +- `getLogPackage()` → `com.hiveops.logcollect` +- `getDependencies()` → empty (no ordering constraints) +- `isEnabled(ctx)` → true when `agent.endpoint` **or** `incident.endpoint` is non-blank in the main properties. +- `initialize(ctx)` → picks the settings prefix (`agent` if `agent.endpoint` is set, else `incident`), builds an `HttpClientSettings` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`, and caches `country` + `atmName` from the `ModuleContext`. +- `start()` → creates a single-thread daemon `ScheduledExecutorService` (thread name `log-collect-path`) and schedules `poll()` at a fixed `POLL_INTERVAL_SECONDS = 30` rate. +- `stop()` → `scheduler.shutdownNow()`. + +## Poll → detect → collect → emit flow + +``` +every 30s poll() + └─ FileUploadClient.queryCurrentAtmPendingTask(country, atmName, "LOG_COLLECT_PATH") + GET {endpoint}/api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH + └─ null / blank taskKind → return (nothing pending; HTTP 204 → null) + └─ task present → handle(client, task) + ├─ targetPath = task.configPatch.get("path") + │ └─ blank → statusReport(FAILED, "No path specified…") + ├─ statusReport(tid, aid, "RUNNING", "Collecting logs from: ") + ├─ AgentLogUploader.uploadPath(targetPath, settings, country, atmName, tid) + │ POST {endpoint}/api/agent/{country}/{atm}/log/upload?taskId={tid} + └─ statusReport(tid, aid, "COMPLETED", "…uploaded successfully") + └─ on exception → statusReport(FAILED, e.getMessage()) +``` + +### `AgentLogUploader.uploadPath(...)` (in `hiveops-agent-journal`) + +1. Normalize `\`→`/` and resolve the path. Missing path → `IOException("Path does not exist…")`. +2. If it's a regular file, collect just that file; otherwise `Files.walkFileTree` collecting **every** file. `visitFileFailed` logs and skips unreadable files (`CONTINUE`). +3. Empty result → `IOException("No files found at path…")`. +4. Build a temp ZIP (`hiveops-path-logs-*.zip`) with entries relative to the target root (single-file case roots at the parent). +5. Multipart-POST the ZIP to `…/log/upload?taskId={tid}` — boundary `----HiveOpsLogUploadBoundary`, form part name `file`, filename `hiveops-logs.zip`, `Content-Type: application/zip`. Auth headers/host applied via `HttpClientHelper.applyParameters` / `applyParameterHost`. +6. Non-2xx logs the error body; the temp ZIP is always deleted in `finally`. + +The `taskId` query param is what lets the backend attach the upload to a trackable `AtmLogCollect` record for that task. + +## How events reach the platform + +This module reaches the platform over the **agent HTTP task API**, not the journal-event pipeline. Three calls, all against `settings.getEndpoint()` (the `agent.endpoint`/`incident.endpoint` base — served by **agent-proxy** in production, which proxies to incident): + +| Purpose | Method + path | +|---------|---------------| +| Poll for work | `GET /api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH` | +| Status updates | `POST /api/agent/tasks/{tid}/status` body `{ status, message }` | +| Deliver the bundle | `POST /api/agent/{country}/{atm}/log/upload?taskId={tid}` (multipart ZIP) | + +Errors in `poll()` are classified: `IOException`/`SocketException` → warn ("transient network error"); `com.hiveops.http.Utils.MyException` (backend error) → warn ("server error"); anything else → error with stack trace. The poll loop survives all of them and retries on the next tick. + +## Platform abstraction + +The module itself uses **no** `PlatformService` — it operates on whatever path string the task supplies and relies on `AgentLogUploader`'s `\`→`/` normalization to accept Windows paths. (By contrast the `INSTALL_MODULE` handler that *delivers* this jar does use `PlatformService` for `restartService` / scripts path.) + +## `CurrentAtmPendingTask` fields used + +`tid`, `aid`, `taskKind`, and `configPatch` (a `Map`; only key `path` is read). Other fields on the DTO (`kb`, `paths`, `artifactCdnUrl`, `cdnToken`) are ignored by this module. diff --git a/backend/src/main/resources/content/agent/log-collect/claude.md b/backend/src/main/resources/content/agent/log-collect/claude.md new file mode 100644 index 0000000..c0d8184 --- /dev/null +++ b/backend/src/main/resources/content/agent/log-collect/claude.md @@ -0,0 +1,77 @@ +--- +module: agent.log-collect +title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-log-collect` source 2026-07-01. Every key/string below is from code; nothing invented. + +## Identity +- Module name (`AgentModule.getName()`): **`log-collect-path`** +- Maven artifact / jar: **`hiveops-module-log-collect`** → `hiveops-module-log-collect.jar` (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`) +- Entry class: `com.hiveops.logcollect.LogCollectPathModule` (SPI: `META-INF/services/com.hiveops.core.module.AgentModule`) +- Uploader: `com.hiveops.logs.AgentLogUploader.uploadPath(...)` (from `hiveops-agent-journal`) +- Log package (`getLogPackage()`): `com.hiveops.logcollect` +- Fleet task kind it implements: **`LOG_COLLECT_PATH`** +- Dependencies: none (`getDependencies()` empty) + +## Enable / disable +- Enabled when **`agent.endpoint`** OR **`incident.endpoint`** is non-blank. Both blank → `isEnabled` false, module never starts. +- Prefix selection: `agent.endpoint` set → prefix `agent`; else prefix `incident`. +- Disable explicitly: add `log-collect-path` to `modules.disabled` in `hiveops.properties`. + +## Config property keys (read, not registered) +No `log-collect.*` keys exist. It reuses HTTP settings via `HttpClientHelper.loadSettingsFromProperties(props, prefix)`: + +| Key | Notes | +|-----|-------| +| `agent.endpoint` / `incident.endpoint` | base URL; also the enable switch | +| `{prefix}.host` | host header | +| `{prefix}.sim` | routing hint | +| `{prefix}.to.connect` | default `10000` (ms) | +| `{prefix}.to.read` | default `10000` (ms) | +| `{prefix}.institution.key` | fallback → `agent.institution.key` → `incident.institution.key` | +| `agent.auth.token` | bearer, always this key regardless of prefix | + +- Poll interval = **30s**, hardcoded constant `POLL_INTERVAL_SECONDS`. NOT configurable. + +## Task-status strings it emits (`POST /api/agent/tasks/{tid}/status`) +- `RUNNING` — "Collecting logs from: " +- `COMPLETED` — "Log collection uploaded successfully" +- `FAILED` — "No path specified in task configPatch" | `` + +It emits **no** journal/incident events, no `IAT_*` events. Status reports only. + +## HTTP endpoints (base = `settings.getEndpoint()`) +| Call | Verb + path | +|------|-------------| +| Poll | `GET /api/agent/{country}/{atm}/task?kind=LOG_COLLECT_PATH` | +| Status | `POST /api/agent/tasks/{tid}/status` → `{status,message}` | +| Upload | `POST /api/agent/{country}/{atm}/log/upload?taskId={tid}` (multipart) | + +- Multipart: boundary `----HiveOpsLogUploadBoundary`, part name `file`, filename `hiveops-logs.zip`, `Content-Type: application/zip`. + +## Task input contract +- Target read from `task.configPatch.get("path")` only. Blank → `FAILED`. +- Accepts a directory (recursive, **all** files, any extension) or a single regular file. +- Windows paths OK: `\` is normalized to `/`. +- Unreadable files skipped (logged), rest still uploaded. Empty/missing path → `IOException` → `FAILED`. + +## Fleet delivery (`INSTALL_MODULE`) +- Push jar `hiveops-module-log-collect.jar` via artifact (`task.paths[0]`) or url (`configPatch.url` + `filename`). +- Filename MUST end `.jar`. Agent writes to `modules/`, then **restarts the agent service** to load it (`ProcessInstallModule`). + +## Gotchas +- Silent-ish poll failures: network/backend errors are `warn`-logged and retried next tick, not surfaced as task FAILED. +- Whole target tree is zipped to a temp file first — large dirs = large temp file + memory/disk pressure. Temp ZIP deleted in `finally`. +- `queryCurrentAtmPendingTask` returns `null` on HTTP 204 (nothing pending) — normal, not an error. +- `taskId` query param is required for the backend to create the trackable `AtmLogCollect` record; don't strip it. + +## Do NOT +- Do NOT expect `log-collect.*` config keys — none exist; don't add one for poll interval without code. +- Do NOT assume it tails/watches logs continuously — it is poll-then-pull, task-driven only. +- Do NOT confuse with the built-in `LOG_COLLECT` command (`AgentLogUploader.uploadDirectory`, agent `logs/` only, `.log`/`.log.gz`). This module is `LOG_COLLECT_PATH` = arbitrary path, all files. +- Do NOT look for journal/incident events from this module — there are none. +- Do NOT rely on `configPatch` keys other than `path` (e.g. `url`/`filename` belong to `INSTALL_MODULE`, not this task). diff --git a/backend/src/main/resources/content/agent/log-collect/internal.md b/backend/src/main/resources/content/agent/log-collect/internal.md new file mode 100644 index 0000000..15c64f0 --- /dev/null +++ b/backend/src/main/resources/content/agent/log-collect/internal.md @@ -0,0 +1,71 @@ +--- +module: agent.log-collect +title: Log Collect Path — on-demand file/log pull from an arbitrary ATM path +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support view.** Confirmed against `hiveops-module-log-collect` source (`com.hiveops.logcollect.LogCollectPathModule`) 2026-07-01. Dev-facing; not customer material. + +## What it does + +The Log Collect Path module lets a fleet operator pull **any file or directory** off an ATM on demand — not just the agent's own logs. It implements the `LOG_COLLECT_PATH` fleet task kind: + +1. The module polls the agent endpoint every **30 seconds** for a pending `LOG_COLLECT_PATH` task for this ATM. +2. When one arrives, it reads the target path from the task's `configPatch.path`. +3. It walks that path recursively, ZIPs every readable file, and uploads the bundle to the backend tagged with the task id so operators can download it (e.g. the **Other Logs** tab in device detail). +4. It reports task status back (`RUNNING` → `COMPLETED`, or `FAILED`). + +It does **not** monitor anything continuously and it emits **no journal/incident events** — it is a task-driven, request/response file collector. There is no schedule, watch, or tail; the only trigger is an operator-created fleet task. + +## What it monitors / why + +Nothing passively. The "monitor" is a 30-second poll for pending tasks. Use cases: + +- Grab a vendor log directory (dispenser, EPP, XFS logs) that the standard `LOG_COLLECT` command doesn't bundle. +- Pull a specific config/trace file from an arbitrary path during an investigation. +- Retrieve a single file (the module handles a regular-file target as well as a directory). + +Contrast with the sibling built-in `LOG_COLLECT` command (in `AgentLogUploader.uploadDirectory`), which only bundles the agent's own `logs/` tree (`.log` / `.log.gz`). `LOG_COLLECT_PATH` collects **all files** under the requested path regardless of extension. + +## Config properties it registers + +The module registers **no dedicated `log-collect.*` properties**. It reuses the agent's existing HTTP client settings. It is **enabled** whenever either endpoint property is set: + +| Property | Purpose | Default | +|----------|---------|---------| +| `agent.endpoint` | Agent-proxy base URL (preferred). If set, the module uses the `agent.*` settings prefix. | — | +| `incident.endpoint` | Legacy incident base URL. Used only if `agent.endpoint` is blank. | — | + +Derived HTTP settings (loaded via the chosen `{prefix}` = `agent` or `incident`): + +| Property | Purpose | Default | +|----------|---------|---------| +| `{prefix}.host` | Host header override | — | +| `{prefix}.sim` | Sim/routing hint | — | +| `{prefix}.to.connect` | Connect timeout (ms) | `10000` | +| `{prefix}.to.read` | Read timeout (ms) | `10000` | +| `{prefix}.institution.key` | Institution key; falls back to `agent.institution.key` then `incident.institution.key` | — | +| `agent.auth.token` | Bearer token — always read as `agent.auth.token` regardless of prefix | — | + +The **30-second poll interval is a hardcoded constant** (`POLL_INTERVAL_SECONDS`), not a property — it cannot be tuned via config. + +## How it's enabled / deployed to ATMs + +The module ships as its own jar, `hiveops-module-log-collect.jar`, discovered at agent startup via the ServiceLoader SPI (`META-INF/services/com.hiveops.core.module.AgentModule`). + +- **Standard build:** it's a `` in the agent parent build; if the jar is on the classpath / in the agent `modules/` directory, it auto-registers. +- **Fleet delivery (`INSTALL_MODULE`):** push an `INSTALL_MODULE` task carrying `hiveops-module-log-collect.jar` (artifact or `configPatch.url`+`filename`). The agent drops it into `modules/` and **restarts the agent service** to load it (see `ProcessInstallModule`). Filename must end in `.jar`. +- **Disable:** add `log-collect-path` to `modules.disabled` in `hiveops.properties`, or unset both `agent.endpoint` and `incident.endpoint` (then `isEnabled` returns false). + +## Common failure modes / what to check + +| Symptom | Likely cause / check | +|---------|----------------------| +| Task stuck `FAILED` immediately, message "No path specified in task configPatch" | The task was created without `configPatch.path`. Recreate with a path. | +| `FAILED` "Path does not exist" / "No files found at path" | The path is wrong for that OS, or empty. Windows paths are accepted with `\` (the module normalizes `\`→`/`). | +| Task never picked up | Module disabled, agent offline, or neither `agent.endpoint`/`incident.endpoint` set → module didn't start. Check `logs/modules/…` for "LogCollectPathModule started". | +| Poll warns "transient network error" / "server error" | Endpoint unreachable or backend 4xx/5xx; poll retries on the next 30s tick — not fatal. | +| Upload returns non-2xx | Check the backend `/api/agent/{country}/{atm}/log/upload` route (served by agent-proxy) and the bearer token / institution key. | +| Huge/locked files | Unreadable files are skipped with a warning (`visitFileFailed`); the ZIP still uploads with the rest. Be mindful of very large target dirs — the whole tree is zipped to a temp file first. | diff --git a/backend/src/main/resources/content/agent/log/architect.md b/backend/src/main/resources/content/agent/log/architect.md new file mode 100644 index 0000000..d7c51f3 --- /dev/null +++ b/backend/src/main/resources/content/agent/log/architect.md @@ -0,0 +1,88 @@ +--- +module: agent.log +title: Agent Log — self-log upload & error-to-incident monitoring +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · how it's built.** Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01. See [technical.agent] for agent-wide module/lifecycle context. + +## Maven module & packaging + +`hiveops-module-log` (artifact `hiveops-module-log`, own version `1.0.0`, parented by `hiveops-agent-parent` `4.4.3`). Built as a standalone jar with `${project.artifactId}` → `hiveops-module-log.jar`, a drop-in for the agent's `modules/` directory (not merged into the fat JAR). + +Dependencies are all **`provided`** scope — supplied by the running fat JAR, not bundled: +- `hiveops-core` — the `AgentModule` SPI + `HttpClientHelper`/`HttpClientSettings`. +- `hiveops-agent-journal` — provides **`AgentLogUploader`** (the multipart/zip upload helper `LogUploadModule` delegates to). *Note: `hiveops-agent-journal` is in the `atm` build set but not the `srv` set, while `hiveops-module-log` is in both; whether the srv fat JAR still carries `AgentLogUploader` on the classpath is **unverified** — flag if a srv device throws `NoClassDefFoundError` on log upload.* +- `log4j-api`, `commons-lang3`, `commons-io`. + +Two implementations are registered via the ServiceLoader SPI file `META-INF/services/com.hiveops.core.module.AgentModule`: +``` +com.hiveops.logs.LogUploadModule +com.hiveops.logs.LogMonitorModule +``` + +## Classes and flow + +| Class | Role | +|---|---| +| `LogUploadModule` | `AgentModule` `log-upload` — scheduler that ships the whole log file on an interval. | +| `LogMonitorModule` | `AgentModule` `log-monitor` (depends on `log-upload`) — scheduler that tails the log for errors and reports summaries. | +| `AgentLogUploader` | *(in `hiveops-agent-journal`)* static helper doing the actual multipart POST / ZIP bundling. Shared with the fleet log-collect commands. | + +Neither module overrides `getLogPackage()`, so their own log output goes to the **root** logger (`hiveops-agent.log`) rather than a dedicated `logs/modules/*.log` — which is deliberate here, since that root file is exactly what they monitor and upload. Neither overrides `isHealthy()` either, so the `ModuleWatchdog` treats them as healthy by default even if their scheduler thread dies *(potential blind spot — unverified as an operational issue)*. + +### log-upload cycle (poll/push, interval-driven) + +1. `initialize()`: resolve endpoint (`agent.endpoint` else `incident.endpoint`; disable if neither), build `HttpClientSettings` via `HttpClientHelper.loadSettingsFromProperties(props, prefix)` where `prefix` is `"agent"` or `"incident"`, read `country`/`atmName` from `ModuleContext`, read `log.upload.interval.minutes` (default 15), resolve `logFilePath`. +2. `start()`: single daemon thread `log-upload`, `scheduleAtFixedRate(doUpload, 0, interval, MINUTES)` — **fires immediately** then every interval. +3. `doUpload()` → `AgentLogUploader.upload(logFilePath, settings, country, atmName)`: + - Skips silently if the file is absent. + - Multipart POST to `{endpoint}/api/agent/{country}/{atm}/log/upload` (boundary `----HiveOpsLogUploadBoundary`, part `file`, filename `hiveops-agent.log`, `Content-Type: text/plain`); logs HTTP status/elapsed/bytes. +4. `stop()`: `scheduler.shutdownNow()`. + +### log-monitor cycle (tail → detect → report) + +1. `initialize()`: same endpoint/settings resolution; read the four `log.monitor.*` tunables; resolve `logFilePath`. +2. `start()`: daemon thread `log-monitor`, `scheduleAtFixedRate(doMonitor, interval, interval, MINUTES)` — **first run delayed one interval** so the log settles. +3. `doMonitor()`: + - Track `lastCheckedPosition` (byte offset). If file shrank (`position > size`) → treat as rotation, reset to 0. If `position == size` → nothing new, return. + - `scanNewLines(fileSize)`: `RandomAccessFile.seek(lastCheckedPosition)`, read line-by-line, re-decode each line from ISO-8859-1 bytes → UTF-8 (works around `RandomAccessFile.readLine()`'s charset), match against `LOG_LEVEL_PATTERN` = `^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]\d+\s+(ERROR|FATAL|WARN)\s+(.+)$`. `WARN` → `warnCount`; `ERROR`/`FATAL` → `errorCount` (+ up to `maxSamples` sample lines, each truncated to 200 chars). + - Advance `lastCheckedPosition = fileSize`. + - If `errorCount < errorThreshold` → return. If `lastReportedAt > 0` and within `cooldownMinutes` → return. + - Else `reportToBackend()` and set `lastReportedAt = now`. +4. `reportToBackend()`: hand-builds JSON `{"errorCount":N,"warnCount":N,"sampleErrors":[...]}` (no Jackson — its own `jsonEscape`), `POST` with `Content-Type: application/json` to `String.format("%s/atm/log-error?country=%s&name=%s", endpoint, country, name)`. Accepts HTTP 200/201; logs (truncated) error body otherwise. Never throws out of the scheduler. + +All state (`lastCheckedPosition`, `lastReportedAt`) is **in-memory only** — an agent restart resets the tail offset to 0, so the first post-restart scan re-reads the whole current file. + +## How events/results reach the platform + +This module has **no** journal/incident `EventType` (`IAT_*`, `CARD_READER_*`, …) integration and does **not** use `IncidentEventClient` — see [technical.agent] "API surface" for that separate path. It also does not report fleet task status. It has exactly two HTTP touchpoints, both terminating at **agent-proxy** (which owns agent log endpoints natively and forwards to incident's internal API): + +``` +LogUploadModule ──► AgentLogUploader.upload() + POST {endpoint}/api/agent/{country}/{atm}/log/upload (multipart) + │ + ▼ agent-proxy AgentLogController.uploadLog() (50 MB cap → 413) + → IncidentInternalService.uploadLog() → incident internal store + +LogMonitorModule ──► reportToBackend() + POST {endpoint}/atm/log-error?country=&name= (JSON summary) + │ ⚠ mismatch: agent-proxy's error endpoint is + │ POST /api/agent/{country}/{atm}/log/errors + ▼ agent-proxy AgentLogController.logErrors() + → IncidentInternalService.logErrors() + → incident InternalAgentController POST /api/internal/agent/{country}/{atm}/log/errors + → LogErrorReportRequest → IncidentAutoCreationService → SOFTWARE_ERROR incident +``` + +**Path discrepancy to be aware of:** the *upload* URL matches agent-proxy's route exactly, but the *monitor's* report URL (`/atm/log-error?country=&name=`) is a legacy shape that does **not** match agent-proxy's `/api/agent/{country}/{atm}/log/errors`. Whether an nginx/proxy rewrite bridges the two, or whether error reporting silently 404s on the current fleet, is **unverified** — verify against the live agent-proxy/nginx config before relying on error-to-incident from this module. + +## Related helper capabilities (not driven by this module) + +`AgentLogUploader` also exposes `uploadDirectory()` (bundles every `*.log`/`*.log.gz` under `logs/` incl. `logs/modules/` into `hiveops-logs.zip`) and `uploadPath(..., taskId)` (zips an arbitrary path, appends `?taskId=`). These back the **`LOG_COLLECT` / `LOG_COLLECT_PATH` fleet commands** and are invoked from the fleet command handlers, **not** from `log-upload`/`log-monitor`. The scheduled upload only ever ships the single current `hiveops-agent.log`. + +## Platform abstraction + +The module does **not** use `PlatformServiceFactory` / `PlatformService` at all — it needs no registry reads, path resolution, or shell-outs. Log path resolution is a plain JVM-system-property (`hiveops-agent.log`) lookup with a `ModuleContext.getStartedFromDir()` fallback, and all I/O is JDK (`RandomAccessFile`, `HttpURLConnection`, `Files`). diff --git a/backend/src/main/resources/content/agent/log/claude.md b/backend/src/main/resources/content/agent/log/claude.md new file mode 100644 index 0000000..efe370b --- /dev/null +++ b/backend/src/main/resources/content/agent/log/claude.md @@ -0,0 +1,72 @@ +--- +module: agent.log +title: Agent Log — self-log upload & error-to-incident monitoring +tab: Claude +order: 40 +audience: bcos +--- + +> **Internal · agent reference.** Terse. Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01. + +## Identity +- Maven artifact: **`hiveops-module-log`** (own version `1.0.0`; parent `hiveops-agent-parent` `4.4.3`) +- Drop-in JAR: **`modules/hiveops-module-log.jar`** (finalName = artifactId; NOT in fat JAR) +- SPI file `META-INF/services/com.hiveops.core.module.AgentModule` registers **two** modules: + - `com.hiveops.logs.LogUploadModule` → `AgentModule.getName()` = **`log-upload`** (no deps) + - `com.hiveops.logs.LogMonitorModule` → `AgentModule.getName()` = **`log-monitor`** (depends on `log-upload`) +- Upload helper: `com.hiveops.logs.AgentLogUploader` — lives in **`hiveops-agent-journal`**, not this jar (provided scope). +- `getLogPackage()` NOT overridden → own logging goes to root **`logs/hiveops-agent.log`** (no `logs/modules/log*.log`). +- `isHealthy()` NOT overridden → always `true` (watchdog won't catch a dead scheduler). +- Monitored/uploaded file: `logs/hiveops-agent.log` under sysprop `hiveops-agent.log` base, else `{startedFromDir}/logs/hiveops-agent.log`. + +## Config property keys (exact) +| Key | Default | Module | +|---|---|---| +| `incident.log.upload.enabled` | `true` | log-upload master switch | +| `log.upload.interval.minutes` | `15` | log-upload cadence (also fires once on start) | +| `incident.log.monitor.enabled` | `true` | log-monitor master switch | +| `log.monitor.interval.minutes` | `5` | log-monitor scan cadence (first scan delayed 1 interval) | +| `log.monitor.cooldown.minutes` | `120` | min gap between error reports | +| `log.monitor.error.threshold` | `1` | min errors in a scan to report | +| `log.monitor.max.samples` | `5` | max sample error lines sent (each ≤200 chars) | +| `agent.endpoint` (pref) / `incident.endpoint` (fallback) | — | HTTP base URL; if both blank → both modules self-disable | +| `agent.*` / `incident.*` client settings | — | auth token / host header via `HttpClientHelper.loadSettingsFromProperties(props, "agent"\|"incident")` | +| `modules.disabled` | — | comma list; include `log-upload` and/or `log-monitor` to disable | + +- `isEnabled()` = endpoint set **AND** the module's own `enabled` flag true **AND** settings built. +- No `getDefaultConfiguration()` override — defaults are the inline `getProperty(key, default)` values above only. + +## Events / reports emitted +- **NO journal `EventType` / `IAT_*` events.** Does not call `IncidentEventClient`. Does NOT report fleet task status. +- **log-upload:** multipart `POST {endpoint}/api/agent/{country}/{atm}/log/upload` — boundary `----HiveOpsLogUploadBoundary`, part `file`, filename `hiveops-agent.log`, `text/plain`. (matches agent-proxy `AgentLogController`.) +- **log-monitor:** JSON `POST {endpoint}/atm/log-error?country={c}&name={n}` — body `{"errorCount":N,"warnCount":N,"sampleErrors":[...]}`. Backend → **`SOFTWARE_ERROR`** incident (dedup + parked/OOS suppression in `IncidentAutoCreationService`). + +## Error-line detection +- Regex: `^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]\d+\s+(ERROR|FATAL|WARN)\s+(.+)$` +- `ERROR`+`FATAL` → `errorCount`; `WARN` → `warnCount` (counted, not reported as errors). +- Tails via `RandomAccessFile.seek(lastCheckedPosition)`; re-decodes each line ISO-8859-1 bytes → UTF-8. +- Rotation handling: if file size < last offset → reset offset to 0. Offsets are **in-memory only** (restart re-reads whole current file). + +## Deployment +- In **BOTH** `atm` and `srv` module sets of `deployment/build-dist.sh` → ships in every install/patch ZIP. +- On by default on any device with an endpoint set. +- Disable: `modules.disabled=log-upload,log-monitor` via `CONFIG_UPDATE` fleet task. +- Retrofit: `INSTALL_MODULE` fleet task (drops jar, restarts agent) or full agent update. +- Upload cap: agent-proxy rejects >**50 MB** with `413`. + +## Gotchas +- **Monitor endpoint mismatch:** module POSTs `/atm/log-error?country=&name=`, but agent-proxy's error route is `/api/agent/{country}/{atm}/log/errors`. Whether a rewrite bridges them / whether error-to-incident currently works is **unverified** — check live agent-proxy + nginx before trusting it. (Upload path is fine — it matches.) +- Two module names, one jar — `modules.disabled` must name `log-upload` and/or `log-monitor`, NOT `hiveops-module-log` / `log`. +- Only the **single current** `hiveops-agent.log` is uploaded on schedule — rotated `.gz` files are NOT (those come via the separate `LOG_COLLECT`/`LOG_COLLECT_PATH` fleet commands through the same `AgentLogUploader`). +- First error scan is delayed one full `log.monitor.interval.minutes` after start; a startup error burst won't report until then. +- Sample lines truncated to 200 chars; only up to `max.samples` errors sent — the count is full, the samples are not. +- Cooldown + backend dedup mean a chronically-failing agent yields **one** `SOFTWARE_ERROR` incident, not a stream. +- `AgentLogUploader` is in `hiveops-agent-journal` (atm set), not this jar — srv-set classpath carrying it is **unverified**. + +## Do NOT +- Do NOT expect journal/hardware events (`IAT_*`, `CARD_READER_*`, `CASSETTE_*`, any `EventType`) from this module — self-log only. +- Do NOT expect fleet task status reports — this module reports none. +- Do NOT invent extra property namespaces — only the `incident.log.*` / `log.upload.*` / `log.monitor.*` keys above exist. +- Do NOT assume error-to-incident works without verifying the `/atm/log-error` vs `/api/agent/.../log/errors` routing on the live proxy. +- Do NOT disable via `modules.disabled=log` — use the exact names `log-upload` / `log-monitor`. +- Do NOT look for a `logs/modules/log*.log` — this module logs to the root `hiveops-agent.log`. diff --git a/backend/src/main/resources/content/agent/log/internal.md b/backend/src/main/resources/content/agent/log/internal.md new file mode 100644 index 0000000..67ac88b --- /dev/null +++ b/backend/src/main/resources/content/agent/log/internal.md @@ -0,0 +1,74 @@ +--- +module: agent.log +title: Agent Log — self-log upload & error-to-incident monitoring +tab: Internal +order: 20 +audience: dev +--- + +> **Internal · ops/support view.** Confirmed against `hiveops-module-log` source (`com.hiveops.logs`) 2026-07-01. + +## What it does (and doesn't) + +`hiveops-module-log` is the agent's **self-observability** module. It watches and ships the agent's **own** operational log file (`logs/hiveops-agent.log`) — it does **not** parse ATM journals, count cassettes, or emit hardware/`IAT_*` events. It exists so HiveIQ can see when the agent itself is unhealthy without SSHing to the ATM. + +It packs **two independent `AgentModule` implementations** into one drop-in JAR: + +| Module name | Class | Job | +|---|---|---| +| `log-upload` | `LogUploadModule` | Periodically POSTs the whole `hiveops-agent.log` to the backend so support can read an ATM's agent log from the platform. | +| `log-monitor` | `LogMonitorModule` | Tails the same log for `ERROR`/`FATAL` lines and, when enough pile up, reports a summary that the backend turns into a **`SOFTWARE_ERROR` incident**. | + +`log-monitor` declares a dependency on `log-upload` (so the shared log-path resolution runs first), but the two run on separate schedulers and can be enabled/disabled independently. + +Flow at a glance: +- **log-upload:** on start, and then every `log.upload.interval.minutes` (default 15), multipart-POST `logs/hiveops-agent.log` → `{endpoint}/api/agent/{country}/{atm}/log/upload`. +- **log-monitor:** every `log.monitor.interval.minutes` (default 5), read only the bytes appended since last check, count `ERROR`+`FATAL` (and `WARN` separately). If errors ≥ `log.monitor.error.threshold` **and** the cooldown has elapsed, POST a JSON summary `{errorCount, warnCount, sampleErrors[]}` → the backend creates/dedups a `SOFTWARE_ERROR` incident for that ATM. + +## Config properties it registers + +All read from `hiveops.properties` (main properties). There is no `getDefaultConfiguration()` override — defaults below are the inline `getProperty(key, default)` fallbacks in code. + +**log-upload:** + +| Key | Default | Meaning | +|---|---|---| +| `incident.log.upload.enabled` | `true` | Master switch for the upload module. | +| `log.upload.interval.minutes` | `15` | Upload cadence (also fires once immediately on start). | + +**log-monitor:** + +| Key | Default | Meaning | +|---|---|---| +| `incident.log.monitor.enabled` | `true` | Master switch for the monitor module. | +| `log.monitor.interval.minutes` | `5` | How often the log is scanned for new error lines. | +| `log.monitor.cooldown.minutes` | `120` | Minimum gap between two error reports (anti-spam). | +| `log.monitor.error.threshold` | `1` | Minimum error count in a scan before a report is sent. | +| `log.monitor.max.samples` | `5` | Max error lines included as `sampleErrors` (each truncated to 200 chars). | + +**Shared (not owned by this module):** both modules pick the HTTP base URL from `agent.endpoint` (preferred) or `incident.endpoint` (fallback), and build their HTTP client via the matching `agent.*` / `incident.*` settings (auth token, host header, etc.). If neither endpoint is set, both modules self-disable. + +The monitored/uploaded file path is `logs/hiveops-agent.log` under the base given by the JVM system property `hiveops-agent.log` (set by `AgentApplication`), falling back to `{startedFromDir}/logs/hiveops-agent.log`. + +## Enabled / deployed to ATMs + +- **Enable condition:** each sub-module self-enables when `agent.endpoint` **or** `incident.endpoint` is set **and** its own `incident.log.upload.enabled` / `incident.log.monitor.enabled` flag is `true` (both default true). So on any normally-configured device both are on by default. +- **Ships everywhere:** `hiveops-module-log` is in **both** the `atm` and `srv` module sets of `deployment/build-dist.sh`, so it's in every standard install/patch ZIP as the drop-in `modules/hiveops-module-log.jar` (not baked into the fat JAR). +- **Disable per ATM/institution:** push `modules.disabled=log-upload,log-monitor` (comma list; include either or both names) via a `CONFIG_UPDATE` fleet task. Turning off just the noise: set `incident.log.monitor.enabled=false` but leave upload on, or vice-versa. +- **Retrofit an ATM without it:** deliver `modules/hiveops-module-log.jar` via a fleet `INSTALL_MODULE` task (drops the JAR and restarts the agent), or via a full agent update. + +## What the backend does with it + +- **Log upload** → agent-proxy `AgentLogController` `POST /api/agent/{country}/{atm}/log/upload` → forwarded to incident's internal API. Agent-proxy rejects uploads over **50 MB** with `413 Payload Too Large`. +- **Error report** → the backend maps the report to a **`SOFTWARE_ERROR`** incident via `IncidentAutoCreationService`. That path **suppresses** creation if the ATM is parked or `OUT_OF_SERVICE`, and **dedups** against an already-active `SOFTWARE_ERROR` incident for the same ATM (so a flapping agent gives one incident, not hundreds). + +## Common failure modes / what to check + +| Symptom | Likely cause | What to check | +|---|---|---| +| No agent log ever appears in the platform for an ATM | Module disabled, or no `agent.endpoint`/`incident.endpoint` on that ATM | Check `modules.disabled`; confirm an endpoint is set; look for "Log upload module initialized" in the ATM's `hiveops-agent.log`. | +| Log upload rejected | File exceeds 50 MB | Agent-proxy returns 413; check log-rotation on the ATM — the module uploads the single current `hiveops-agent.log`, not rotated `.gz` files. | +| Expected a `SOFTWARE_ERROR` incident but none created | ATM parked/`OUT_OF_SERVICE`, or an active `SOFTWARE_ERROR` incident already open (dedup), or still inside the 120-min cooldown | Check ATM status and existing open incidents; check `log.monitor.cooldown.minutes`. | +| Error incidents never fire even though the agent logs errors | Errors don't match the expected Log4j2 line shape (`yyyy-MM-dd HH:mm:ss.SSS LEVEL ...`), so the scanner doesn't count them | Confirm the ATM's `log4j2.xml` pattern starts with timestamp + level; non-standard patterns are silently skipped. | +| Flood of errors right after startup produced nothing | First monitor scan is delayed one full interval (default 5 min) after start | Expected; give it an interval. | +| Report seems to go nowhere / backend 404 | **Path mismatch (see gotcha):** the monitor POSTs to `{endpoint}/atm/log-error?country=&name=`, but agent-proxy exposes the error endpoint at `/api/agent/{country}/{atm}/log/errors` | If error-to-incident stops working after an endpoint/proxy change, verify how `/atm/log-error` is routed for that fleet — see the Claude tab "Do NOT / gotchas". *(routing of the legacy path is unverified.)* | diff --git a/backend/src/main/resources/content/agent/nh-tcr-events/architect.md b/backend/src/main/resources/content/agent/nh-tcr-events/architect.md new file mode 100644 index 0000000..325136d --- /dev/null +++ b/backend/src/main/resources/content/agent/nh-tcr-events/architect.md @@ -0,0 +1,94 @@ +--- +module: agent.nh-tcr-events +title: NH TCR Events — cassette + firmware monitoring for Nautilus Hyosung / Glory BRM30 recyclers +tab: Architect +order: 30 +audience: bcos +--- + +> **Internal · architecture.** Written 2026-07-01 from `hiveops-module-nh-tcr-events` source. Verify against code before relying on specifics. + +## Module shape + +Package `com.hiveops.nhtcr`, three classes, no controllers/DB/Kafka (this is an agent module, not a microservice — see [technical.agent]): + +- **`NhTcrEventsModule`** — the `AgentModule` SPI entry point (registered via `META-INF/services/com.hiveops.core.module.AgentModule` → `com.hiveops.nhtcr.NhTcrEventsModule`). `getName()` = `nh-tcr-events`, `getVersion()` = `1.0.0`, `getLogPackage()` = `com.hiveops.nhtcr`. Owns lifecycle (`initialize()` → `isEnabled()` → `start()` → `stop()`) and the single shared `ScheduledExecutorService`. +- **`ApLogMonitor`** (package-private) — parses the MoniPlus2S APLog and syncs firmware inventory. +- **`FwEventLogMonitor`** (package-private) — parses the Glory BRM30 FWLogSave circular buffer. + +Both monitors are plain constructed objects (`new ApLogMonitor(dir, atmName, client)`), not their own `AgentModule`s — `NhTcrEventsModule` drives both from one poll loop. Each monitor is created only if its directory property is set **and** resolves to an existing directory; `ready = apLogMonitor != null || fwEventLogMonitor != null` gates `isEnabled()`. + +## Lifecycle & gating (`initialize()`) + +1. Reads `device.type`; if not `NH_TCR` (uppercased/trimmed) → logs and returns (module stays disabled, `ready=false`). +2. Reads `nh.tcr.ap.log.dir` and `nh.tcr.fw.log.dir`; if **both** null → disabled. +3. Resolves the endpoint: `agent.endpoint` if present, else `incident.endpoint`; if neither → disabled. `prefix` is `"agent"` or `"incident"` accordingly. +4. Builds one `IncidentEventClient` from `HttpClientHelper.loadSettingsFromProperties(props, prefix)` + `context.getAtmName()` + `context.getCountry()`, shared by both monitors. +5. Constructs each monitor only if its dir exists (else a per-monitor warning; the other monitor can still run). +6. Reads `nh.tcr.events.poll.interval.sec` (default `DEFAULT_POLL_SEC=30`) into `pollSec`. + +`start()` spins one daemon thread `"nh-tcr-events"` and `scheduleAtFixedRate(this::poll, 0, pollSec, SECONDS)` — **`pollSec` is honored here** (contrast the `atec-tcr-events` sibling, whose poll interval is a no-op bug). `poll()` calls `apLogMonitor.poll()` then `fwEventLogMonitor.poll()`, wrapping both in a try/catch so one monitor's exception can't kill the scheduler. + +## Flow: poll → parse → detect → emit + +``` +NhTcrEventsModule.start() + → ScheduledExecutorService (daemon "nh-tcr-events", fixed-rate, every pollSec sec) + → poll() + → apLogMonitor.poll() // cassette status + firmware inventory + → fwEventLogMonitor.poll() // EP restarts +``` + +### `ApLogMonitor` — date-subfolder tail + header scrape + +- **File selection:** `rotateDailyFileIfNeeded()` compares `LocalDate.now()` to `currentDate`; on a new day it recomputes `currentFile = baseLogDir/{YYYYMMDD}/APLog{YYYYMMDD}.log` (`DateTimeFormatter.ofPattern("yyyyMMdd")`), resets `fileOffset=0`, `headerParsed=false`, and clears the `slotStatus` map. +- **Header sync (once per daily file):** on first poll of a file, `parseAndSyncHeader()` opens a **separate** `RandomAccessFile` from offset 0 (so it doesn't disturb `fileOffset`), reads the block between the two `={20,}` delimiter lines, and extracts versions. Two line shapes: `-