feat: implement smoketest and extend host provisioning with template linking

- Add runSmoketest mutation to automate end-to-end verification.

- Add SmoketestExecutor and HostDeleter to support automated testing and cleanup.

- Extend createHost and importHosts to allow linking templates by name or ID.

- Update docs/howtos/cookbook.md with new recipe steps and AI/MCP guidance.

- Update .junie/guidelines.md with new verification and deployment standards.

- Add src/test/template_link.test.ts and update existing tests to cover new functionality.

- Regenerate GraphQL types to match schema updates.
This commit is contained in:
Andreas Hilbig 2026-01-31 11:46:02 +01:00
parent b56255ffaa
commit 67357d0bc3
20 changed files with 690 additions and 50 deletions

20
.idea/workspace.xml generated
View file

@ -5,17 +5,12 @@
</component>
<component name="ChangeListManager">
<list default="true" id="d7a71994-2699-4ae4-9fd2-ee13b7f33d35" name="Changes" comment="docs: refactor documentation and upgrade to Node.js 24&#10;&#10;This commit upgrades the project to Node.js 24 (LTS) and performs a major refactoring of the documentation to support both advanced users and AI-based automation (MCP).&#10;&#10;Changes:&#10;- Environment &amp; CI/CD:&#10; - Set Node.js version to &gt;=24 in package.json and .nvmrc.&#10; - Updated Dockerfile to use Node 24 base image.&#10; - Updated @types/node to ^24.10.9.&#10;- Documentation:&#10; - Refactored README.md with comprehensive technical reference, configuration details, and Zabbix-to-GraphQL mapping.&#10; - Created docs/howtos/cookbook.md with practical recipes for common tasks and AI test generation.&#10; - Updated docs/howtos/mcp.md to emphasize GraphQL's advantages for AI agents and Model Context Protocol.&#10; - Added readme.improvement.plan.md to track documentation evolution.&#10; - Enhanced all how-to guides with improved cross-references and up-to-date information.&#10;- Guidelines:&#10; - Updated .junie/guidelines.md with Node 24 requirements and enhanced commit message standards (Conventional Commits 1.0.0).&#10;- Infrastructure &amp; Code:&#10; - Updated docker-compose.yml with Apollo MCP server integration.&#10; - Refined configuration and schema handling in src/api/ and src/datasources/.&#10; - Synchronized generated TypeScript types with schema updates.">
<change afterPath="$PROJECT_DIR$/.ai/mcp/mcp.json" afterDir="false" />
<change afterPath="$PROJECT_DIR$/roadmap.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.junie/guidelines.md" beforeDir="false" afterPath="$PROJECT_DIR$/.junie/guidelines.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docker-compose.yml" beforeDir="false" afterPath="$PROJECT_DIR$/docker-compose.yml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/README.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/README.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/cookbook.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/cookbook.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/howtos/mcp.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/howtos/mcp.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/mcp-config.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/mcp-config.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/schema/mutations.graphql" beforeDir="false" afterPath="$PROJECT_DIR$/schema/mutations.graphql" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/api/resolvers.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/api/resolvers.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/datasources/zabbix-templates.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/execution/host_importer.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/execution/host_importer.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/schema/generated/graphql.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/schema/generated/graphql.ts" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -26,7 +21,7 @@
<execution />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="111" />
<option name="cachedIndexableFilesCount" value="138" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
@ -215,7 +210,8 @@
<workItem from="1769699975260" duration="75000" />
<workItem from="1769700092648" duration="5212000" />
<workItem from="1769724930397" duration="16056000" />
<workItem from="1769789496322" duration="5496000" />
<workItem from="1769789496322" duration="14281000" />
<workItem from="1769849767328" duration="4117000" />
</task>
<task id="LOCAL-00001" summary="chore: Update IntelliJ workspace settings and add GitHub Actions workflow for Docker deployment">
<option name="closed" value="true" />

View file

@ -28,14 +28,15 @@ The [Roadmap](../roadmap.md) is to be considered as outlook giving constraints o
## Common Scripts
- `npm run start`: Launches the development server with `tsx` and `nodemon` for hot-reloading.
- `npm run test`: Executes the Jest test suite.
- `npm run codegen`: Generates TypeScript types based on the GraphQL schema definitions.
- `npm run codegen`: Starts GraphQL Codegen in watch mode (for continuous development).
- `npx graphql-codegen --config codegen.ts`: Generates TypeScript types once (use this for one-off updates).
- `npm run compile`: Compiles TypeScript source files into the `dist/` directory.
- `npm run prod`: Prepares the schema and runs the compiled production build.
## Best Practices & Standards
- **ESM & Imports**: The project uses ECMAScript Modules (ESM). Always use the `.js` extension when importing local files (e.g. `import { Config } from "../common_utils.js";`), even though the source files are `.ts`.
- **Configuration**: Always use the `Config` class to access environment variables. Avoid direct `process.env` calls.
- **Type Safety**: Leverage types generated via `npm run codegen` for resolvers and data handling to ensure consistency with the schema.
- **Type Safety**: Leverage types generated via `npx graphql-codegen --config codegen.ts` (or `npm run codegen` for watch mode) for resolvers and data handling to ensure consistency with the schema.
- **Import Optimization**:
- Always optimize imports before committing.
- Project setting `OPTIMIZE_IMPORTS_BEFORE_PROJECT_COMMIT` is enabled.
@ -44,6 +45,14 @@ The [Roadmap](../roadmap.md) is to be considered as outlook giving constraints o
- **Testing**: Write reproduction tests for bugs and cover new features with both unit and integration tests in `src/test/`.
- **Grammar & Style**: Avoid using a comma after "e.g." or "i.e." (e.g. use "e.g. example" instead of "e.g., example").
## Verification & Deployment
- **Pre-commit Verification**: Always add a verification stage to your plan before committing.
- *Action*: Run the `Smoketest` tool using MCP to ensure basic functionality is intact.
- *Action*: Monitor the API logs for errors after each service restart.
- **Environment Restart**: Always include a step to rebuild and restart the API and MCP server as a final check.
- *Command*: `docker compose up -d --build`
- *Requirement*: Ask the user if everything looks okay before executing the restart, and offer the option to skip this step.
### Documentation Style
- **Bullet Points**: Use bullet points instead of enumerations for lists to maintain consistency across all documentation.
- **Visual Style**: Use icons in headers and bold subjects for primary list items (e.g. `- **Feature**: Description`) to match the `README.md` style.

View file

@ -140,14 +140,15 @@ Execute the `importTemplates` mutation to create the template and items automati
Verify that the new type is available and correctly mapped by creating a test host and querying it.
#### 1. Create a Test Host
Use the `importHosts` mutation (or `createHost` if IDs are already known) to create a host and explicitly set its `deviceType` to `DistanceTrackerDevice`.
Use the `importHosts` mutation (or `createHost` if IDs are already known) to create a host. Set its `deviceType` to `DistanceTrackerDevice` and link it to the `DISTANCE_TRACKER` template (created in Step 3) using the `templateNames` parameter.
```graphql
mutation CreateTestDistanceTracker($host: String!, $groupNames: [String!]!) {
mutation CreateTestDistanceTracker($host: String!, $groupNames: [String!]!, $templateNames: [String]) {
importHosts(hosts: [{
deviceKey: $host,
deviceType: "DistanceTrackerDevice",
groupNames: $groupNames
groupNames: $groupNames,
templateNames: $templateNames
}]) {
hostid
message
@ -209,8 +210,8 @@ For more details on the input fields, see the [Reference: createHost](../../sche
AI agents should prefer using the `importHosts` MCP tool for provisioning as it allows using names for host groups instead of IDs.
```graphql
mutation CreateNewHost($host: String!, $groups: [Int!]!, $templates: [Int!]!) {
createHost(host: $host, hostgroupids: $groups, templateids: $templates) {
mutation CreateNewHost($host: String!, $groups: [Int!]!, $templates: [Int], $templateNames: [String]) {
createHost(host: $host, hostgroupids: $groups, templateids: $templates, templateNames: $templateNames) {
hostids
error {
message

View file

@ -12,8 +12,14 @@ The project uses [GraphQL Codegen](https://the-guild.dev/graphql/codegen) to gen
- **Generated Output**: `src/schema/generated/graphql.ts`
#### How to Regenerate Types
Whenever you modify any `.graphql` files in the `schema/` directory, you must regenerate the TypeScript types:
Whenever you modify any `.graphql` files in the `schema/` directory, you must regenerate the TypeScript types.
For a one-off update (e.g. in a script or before commit):
```bash
npx graphql-codegen --config codegen.ts
```
If you are a developer and want to watch for schema changes continuously:
```bash
npm run codegen
```

View file

@ -1,5 +1,5 @@
mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateids: [Int!]!) {
createHost(host: $host, hostgroupids: $hostgroupids, templateids: $templateids) {
mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateids: [Int], $templateNames: [String]) {
createHost(host: $host, hostgroupids: $hostgroupids, templateids: $templateids, templateNames: $templateNames) {
hostids
error {
message

View file

@ -1,8 +1,9 @@
mutation CreateVerificationHost($deviceKey: String!, $deviceType: String!, $groupNames: [String!]!) {
mutation CreateVerificationHost($deviceKey: String!, $deviceType: String!, $groupNames: [String!]!, $templateNames: [String]) {
importHosts(hosts: [{
deviceKey: $deviceKey,
deviceType: $deviceType,
groupNames: $groupNames
groupNames: $groupNames,
templateNames: $templateNames
}]) {
hostid
message

View file

@ -1,5 +1,6 @@
# Import multiple hosts/devices into Zabbix.
# This is a powerful tool for bulk provisioning of hosts using their names and types.
# It supports linking templates by ID (templateids) or by name (templateNames).
mutation ImportHosts($hosts: [CreateHost!]!) {
importHosts(hosts: $hosts) {
hostid

View file

@ -0,0 +1,14 @@
# Run a complete smoketest: creates a template, host group, and host,
# verifies their creation and linkage, and then cleans up everything.
# Variables: hostName, templateName, groupName
mutation RunSmoketest($hostName: String!, $templateName: String!, $groupName: String!) {
runSmoketest(hostName: $hostName, templateName: $templateName, groupName: $groupName) {
success
message
steps {
name
success
message
}
}
}

View file

@ -11,7 +11,9 @@ type Mutation {
"""List of host group IDs to assign the host to."""
hostgroupids:[Int!]!,
"""List of template IDs to link to the host."""
templateids: [Int!]!,
templateids: [Int],
"""List of template names to link to the host."""
templateNames: [String],
"""Optional location information for the host inventory."""
location: LocationInput
): CreateHostResponse
@ -100,6 +102,78 @@ type Mutation {
"""Wildcard name pattern for template groups to delete."""
name_pattern: String
): [DeleteResponse!]
"""
Delete hosts by their IDs or by a name pattern.
Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
"""
deleteHosts(
"""List of host IDs to delete."""
hostids: [Int!],
"""Wildcard name pattern for hosts to delete."""
name_pattern: String
): [DeleteResponse!]
"""
Delete host groups by their IDs or by a name pattern.
Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
"""
deleteHostGroups(
"""List of host group IDs to delete."""
groupids: [Int!],
"""Wildcard name pattern for host groups to delete."""
name_pattern: String
): [DeleteResponse!]
"""
Runs a smoketest: creates a template, links a host, verifies it, and cleans up.
"""
runSmoketest(
"""Technical name for the smoketest host."""
hostName: String!,
"""Technical name for the smoketest template."""
templateName: String!,
"""Technical name for the smoketest host group."""
groupName: String!
): SmoketestResponse!
}
"""
Response object for the smoketest operation.
"""
type SmoketestResponse {
"""
True if all steps of the smoketest succeeded.
"""
success: Boolean!
"""
Overall status message.
"""
message: String
"""
Detailed results for each step.
"""
steps: [SmoketestStep!]!
}
"""
Results for a single step in the smoketest.
"""
type SmoketestStep {
"""
Name of the step (e.g. 'Create Template').
"""
name: String!
"""
True if the step succeeded.
"""
success: Boolean!
"""
Status message or error message for the step.
"""
message: String
}
####################################################################
@ -413,6 +487,14 @@ input CreateHost {
"""
groupids: [Int]
"""
List of template IDs to link to the host.
"""
templateids: [Int]
"""
List of template names to link to the host.
"""
templateNames: [String]
"""
Location information for the host.
"""
location: LocationInput

View file

@ -25,6 +25,8 @@ import {
} from "../schema/generated/graphql.js";
import {HostImporter} from "../execution/host_importer.js";
import {HostDeleter} from "../execution/host_deleter.js";
import {SmoketestExecutor} from "../execution/smoketest_executor.js";
import {TemplateImporter} from "../execution/template_importer.js";
import {TemplateDeleter} from "../execution/template_deleter.js";
import {HostValueExporter} from "../execution/host_exporter.js";
@ -48,7 +50,11 @@ import {
ZabbixImportUserRolesRequest,
ZabbixQueryUserRolesRequest
} from "../datasources/zabbix-userroles.js";
import {ZabbixQueryTemplateGroupRequest, ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
import {
TemplateHelper,
ZabbixQueryTemplateGroupRequest,
ZabbixQueryTemplatesRequest
} from "../datasources/zabbix-templates.js";
import {zabbixAPI} from "../datasources/zabbix-api.js";
import {GraphQLInterfaceType, GraphQLList} from "graphql/type/index.js";
import {isDevice} from "./resolver_helpers.js";
@ -182,6 +188,17 @@ export function createResolvers(): Resolvers {
zabbixAuthToken,
cookie
}: any) => {
if (args.templateNames?.length) {
const templateidsByName = await TemplateHelper.findTemplateIdsByName(args.templateNames as string[], zabbixAPI, zabbixAuthToken, cookie);
if (!templateidsByName) {
return {
error: {
message: `Unable to find templates: ${args.templateNames}`
}
}
}
args.templateids = (args.templateids || []).concat(templateidsByName);
}
return await new ZabbixCreateHostRequest(zabbixAuthToken, cookie).executeRequestThrowError(
zabbixAPI,
new ParsedArgs(args)
@ -241,6 +258,24 @@ export function createResolvers(): Resolvers {
cookie
}: any) => {
return TemplateDeleter.deleteTemplateGroups(args.groupids, args.name_pattern, zabbixAuthToken, cookie)
},
deleteHosts: async (_parent: any, args: any, {
zabbixAuthToken,
cookie
}: any) => {
return HostDeleter.deleteHosts(args.hostids, args.name_pattern, zabbixAuthToken, cookie)
},
deleteHostGroups: async (_parent: any, args: any, {
zabbixAuthToken,
cookie
}: any) => {
return HostDeleter.deleteHostGroups(args.groupids, args.name_pattern, zabbixAuthToken, cookie)
},
runSmoketest: async (_parent: any, args: any, {
zabbixAuthToken,
cookie
}: any) => {
return SmoketestExecutor.runSmoketest(args.hostName, args.templateName, args.groupName, zabbixAuthToken, cookie)
}
},

View file

@ -69,6 +69,11 @@ export class ZabbixQueryHostgroupsRequest extends ZabbixRequestWithPermissions<Z
}
export class ZabbixDeleteHostGroupsRequest extends ZabbixRequestWithPermissions<{ groupids: string[] }> {
constructor(authToken?: string | null, cookie?: string | null) {
super("hostgroup.delete", authToken, cookie, hostGroupReadWritePermissions);
}
}
export class GroupHelper {
public static groupFullName(groupName: string) {

View file

@ -228,3 +228,9 @@ export class ZabbixCreateHostRequest extends ZabbixRequest<CreateHostResponse> {
return args?.zabbix_params || {};
}
}
export class ZabbixDeleteHostsRequest extends ZabbixRequest<{ hostids: string[] }> {
constructor(authToken?: string | null, cookie?: string | null) {
super("host.delete", authToken, cookie);
}
}

View file

@ -1,4 +1,6 @@
import {ZabbixRequest} from "./zabbix-request.js";
import {ZabbixRequest, ParsedArgs, isZabbixErrorResult} from "./zabbix-request.js";
import {ZabbixAPI} from "./zabbix-api.js";
import {logger} from "../logging/logger.js";
export interface ZabbixQueryTemplateResponse {
@ -65,3 +67,22 @@ export class ZabbixDeleteTemplateGroupsRequest extends ZabbixRequest<{ groupids:
}
export class TemplateHelper {
public static async findTemplateIdsByName(templateNames: string[], zabbixApi: ZabbixAPI, zabbixAuthToken?: string, cookie?: string) {
let result: number[] = []
for (let templateName of templateNames) {
let templates = await new ZabbixQueryTemplatesRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixApi, new ParsedArgs({
filter_name: templateName
}))
if (isZabbixErrorResult(templates) || !templates?.length) {
logger.error(`Unable to find templateName=${templateName}`)
return null
}
result.push(...templates.map((t) => Number(t.templateid)))
}
return result
}
}

View file

@ -0,0 +1,110 @@
import {DeleteResponse} from "../schema/generated/graphql.js";
import {
ZabbixDeleteHostsRequest,
ZabbixQueryHostsGenericRequest,
} from "../datasources/zabbix-hosts.js";
import {
ZabbixDeleteHostGroupsRequest,
ZabbixQueryHostgroupsRequest,
ZabbixQueryHostgroupsParams,
GroupHelper
} from "../datasources/zabbix-hostgroups.js";
import {isZabbixErrorResult, ParsedArgs} from "../datasources/zabbix-request.js";
import {zabbixAPI} from "../datasources/zabbix-api.js";
export class HostDeleter {
public static async deleteHosts(hostids: number[] | null | undefined, name_pattern?: string | null, zabbixAuthToken?: string, cookie?: string): Promise<DeleteResponse[]> {
const result: DeleteResponse[] = [];
let idsToDelete = hostids ? [...hostids] : [];
if (name_pattern) {
const queryResult = await new ZabbixQueryHostsGenericRequest("host.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({ name_pattern: name_pattern }));
if (!isZabbixErrorResult(queryResult) && Array.isArray(queryResult)) {
const foundIds = queryResult.map((t: any) => Number(t.hostid));
// Merge and deduplicate
idsToDelete = Array.from(new Set([...idsToDelete, ...foundIds]));
}
}
if (idsToDelete.length === 0) {
return [];
}
const deleteResult = await new ZabbixDeleteHostsRequest(zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs(idsToDelete));
if (isZabbixErrorResult(deleteResult)) {
let errorMessage = deleteResult.error.message;
if (deleteResult.error.data) {
errorMessage += " " + (typeof deleteResult.error.data === 'string' ? deleteResult.error.data : JSON.stringify(deleteResult.error.data));
}
for (const id of idsToDelete) {
result.push({
id: id,
message: errorMessage,
error: deleteResult.error
});
}
} else if (deleteResult?.hostids) {
for (const id of idsToDelete) {
result.push({
id: id,
message: `Host ${id} deleted successfully`
});
}
}
return result;
}
public static async deleteHostGroups(groupids: number[] | null | undefined, name_pattern?: string | null, zabbixAuthToken?: string, cookie?: string): Promise<DeleteResponse[]> {
const result: DeleteResponse[] = [];
let idsToDelete = groupids ? [...groupids] : [];
if (name_pattern) {
const queryResult = await new ZabbixQueryHostgroupsRequest(zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ZabbixQueryHostgroupsParams({
filter_name: GroupHelper.groupFullName(name_pattern)
}));
if (!isZabbixErrorResult(queryResult) && Array.isArray(queryResult)) {
const foundIds = queryResult.map(g => Number(g.groupid));
// Merge and deduplicate
idsToDelete = Array.from(new Set([...idsToDelete, ...foundIds]));
}
}
if (idsToDelete.length === 0) {
return [];
}
const deleteResult = await new ZabbixDeleteHostGroupsRequest(zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs(idsToDelete));
if (isZabbixErrorResult(deleteResult)) {
let errorMessage = deleteResult.error.message;
if (deleteResult.error.data) {
errorMessage += " " + (typeof deleteResult.error.data === 'string' ? deleteResult.error.data : JSON.stringify(deleteResult.error.data));
}
for (const id of idsToDelete) {
result.push({
id: id,
message: errorMessage,
error: deleteResult.error
});
}
} else if (deleteResult?.groupids) {
for (const id of idsToDelete) {
result.push({
id: id,
message: `Host group ${id} deleted successfully`
});
}
}
return result;
}
}

View file

@ -6,7 +6,8 @@ import {
InputMaybe
} from "../schema/generated/graphql.js";
import {logger} from "../logging/logger.js";
import {ZabbixQueryTemplatesRequest} from "../datasources/zabbix-templates.js";
import {ZabbixCreateHostRequest} from "../datasources/zabbix-hosts.js";
import {ZabbixQueryTemplatesRequest, TemplateHelper} from "../datasources/zabbix-templates.js";
import {isZabbixErrorResult, ParsedArgs, ZabbixErrorResult} from "../datasources/zabbix-request.js";
import {CreateHostGroupResult, GroupHelper, ZabbixCreateHostGroupRequest} from "../datasources/zabbix-hostgroups.js";
import {ZABBIX_EDGE_DEVICE_BASE_GROUP, zabbixAPI} from "../datasources/zabbix-api.js";
@ -110,32 +111,49 @@ export class HostImporter {
break
}
}
let deviceImportResult: {
hostids?: string[];
error?: any;
} = await zabbixAPI.requestByPath("host.create", new ParsedArgs(
let templateids = device.templateids ? [...device.templateids as number[]] : [];
if (device.templateNames?.length) {
const resolvedTemplateids = await TemplateHelper.findTemplateIdsByName(device.templateNames as string[], zabbixAPI, zabbixAuthToken, cookie);
if (resolvedTemplateids) {
templateids.push(...resolvedTemplateids);
} else {
result.push({
deviceKey: device.deviceKey,
message: `Unable to find templates: ${device.templateNames}`
});
continue;
}
}
if (templateids.length === 0) {
const defaultTemplateId = await HostImporter.getTemplateIdForDeviceType(device.deviceType, zabbixAuthToken, cookie);
if (defaultTemplateId) {
templateids.push(defaultTemplateId);
}
}
let deviceImportResult = await new ZabbixCreateHostRequest(zabbixAuthToken, cookie).executeRequestReturnError(zabbixAPI, new ParsedArgs(
{
host: device.deviceKey,
name: device.name,
location: device.location,
templateids: [
await HostImporter.getTemplateIdForDeviceType(
device.deviceType, zabbixAuthToken, cookie)],
templateids: templateids,
hostgroupids: groupids
}
), zabbixAuthToken, cookie)
if (deviceImportResult?.hostids?.length) {
result.push({
deviceKey: device.deviceKey,
hostid: deviceImportResult.hostids[0],
})
} else {
))
if (isZabbixErrorResult(deviceImportResult)) {
result.push({
deviceKey: device.deviceKey,
message: `Unable to import deviceKey=${device.deviceKey}: ${deviceImportResult.error.message}`,
error: deviceImportResult.error
})
} else {
result.push({
deviceKey: device.deviceKey,
hostid: deviceImportResult.hostids![0]?.toString(),
})
}
}

View file

@ -0,0 +1,158 @@
import {SmoketestResponse, SmoketestStep} from "../schema/generated/graphql.js";
import {TemplateImporter} from "./template_importer.js";
import {HostImporter} from "./host_importer.js";
import {HostDeleter} from "./host_deleter.js";
import {TemplateDeleter} from "./template_deleter.js";
import {zabbixAPI} from "../datasources/zabbix-api.js";
import {ZabbixQueryHostsGenericRequest} from "../datasources/zabbix-hosts.js";
import {ParsedArgs} from "../datasources/zabbix-request.js";
export class SmoketestExecutor {
public static async runSmoketest(hostName: string, templateName: string, groupName: string, zabbixAuthToken?: string, cookie?: string): Promise<SmoketestResponse> {
const steps: SmoketestStep[] = [];
let success = true;
try {
// Step 0: Create Template Group
const templateGroupResult = await TemplateImporter.importTemplateGroups([{
groupName: groupName
}], zabbixAuthToken, cookie);
const templateGroupSuccess = !!templateGroupResult?.length && !templateGroupResult[0].error;
steps.push({
name: "Create Template Group",
success: templateGroupSuccess,
message: templateGroupSuccess ? `Template group ${groupName} created` : `Failed: ${templateGroupResult?.[0]?.error?.message || "Unknown error"}`
});
if (!templateGroupSuccess) success = false;
// Step 1: Create Template
if (success) {
const templateResult = await TemplateImporter.importTemplates([{
host: templateName,
name: templateName,
groupNames: [groupName]
}], zabbixAuthToken, cookie);
const templateSuccess = !!templateResult?.length && !templateResult[0].error;
steps.push({
name: "Create Template",
success: templateSuccess,
message: templateSuccess ? `Template ${templateName} created` : `Failed: ${templateResult?.[0]?.error?.message || "Unknown error"}`
});
if (!templateSuccess) success = false;
} else {
steps.push({ name: "Create Template", success: false, message: "Skipped due to previous failures" });
}
// Step 2: Create Host Group
const groupResult = await HostImporter.importHostGroups([{
groupName: groupName
}], zabbixAuthToken, cookie);
const groupSuccess = !!groupResult?.length && !groupResult[0].error;
steps.push({
name: "Create Host Group",
success: groupSuccess,
message: groupSuccess ? `Host group ${groupName} created` : `Failed: ${groupResult?.[0]?.error?.message || "Unknown error"}`
});
if (!groupSuccess) success = false;
// Step 3: Create Host and Link to Template
if (success) {
const hostResult = await HostImporter.importHosts([{
deviceKey: hostName,
deviceType: "ZabbixHost",
groupNames: [groupName],
templateNames: [templateName]
}], zabbixAuthToken, cookie);
const hostSuccess = !!hostResult?.length && !hostResult[0].error;
steps.push({
name: "Create and Link Host",
success: hostSuccess,
message: hostSuccess ? `Host ${hostName} created and linked to ${templateName}` : `Failed: ${hostResult?.[0]?.error?.message || "Unknown error"}`
});
if (!hostSuccess) success = false;
} else {
steps.push({ name: "Create and Link Host", success: false, message: "Skipped due to previous failures" });
}
// Step 4: Verify Host Linkage
if (success) {
const verifyResult = await new ZabbixQueryHostsGenericRequest("host.get", zabbixAuthToken, cookie)
.executeRequestReturnError(zabbixAPI, new ParsedArgs({
filter_host: hostName,
selectParentTemplates: ["name"]
}));
let verified = false;
if (Array.isArray(verifyResult) && verifyResult.length > 0) {
const host = verifyResult[0] as any;
const linkedTemplates = host.parentTemplates || [];
verified = linkedTemplates.some((t: any) => t.name === templateName);
}
steps.push({
name: "Verify Host Linkage",
success: verified,
message: verified ? `Verification successful: Host ${hostName} is linked to ${templateName}` : `Verification failed: Host or linkage not found`
});
if (!verified) success = false;
} else {
steps.push({ name: "Verify Host Linkage", success: false, message: "Skipped due to previous failures" });
}
} catch (error: any) {
success = false;
steps.push({
name: "Execution Error",
success: false,
message: error.message || String(error)
});
} finally {
// Step 5: Cleanup
const cleanupSteps: SmoketestStep[] = [];
// Delete Host
const deleteHostRes = await HostDeleter.deleteHosts(null, hostName, zabbixAuthToken, cookie);
cleanupSteps.push({
name: "Cleanup: Delete Host",
success: deleteHostRes.every(r => !r.error),
message: deleteHostRes.length > 0 ? deleteHostRes[0].message : "Host not found for deletion"
});
// Delete Template
const deleteTemplateRes = await TemplateDeleter.deleteTemplates(null, templateName, zabbixAuthToken, cookie);
cleanupSteps.push({
name: "Cleanup: Delete Template",
success: deleteTemplateRes.every(r => !r.error),
message: deleteTemplateRes.length > 0 ? deleteTemplateRes[0].message : "Template not found for deletion"
});
// Delete Host Group
const deleteGroupRes = await HostDeleter.deleteHostGroups(null, groupName, zabbixAuthToken, cookie);
cleanupSteps.push({
name: "Cleanup: Delete Host Group",
success: deleteGroupRes.every(r => !r.error),
message: deleteGroupRes.length > 0 ? deleteGroupRes[0].message : "Host group not found for deletion"
});
// We also need to delete the template group if it's different or just try to delete it
// In our setup, TemplateImporter creates it if it doesn't exist.
const deleteTemplateGroupRes = await TemplateDeleter.deleteTemplateGroups(null, groupName, zabbixAuthToken, cookie);
cleanupSteps.push({
name: "Cleanup: Delete Template Group",
success: deleteTemplateGroupRes.every(r => !r.error),
message: deleteTemplateGroupRes.length > 0 ? deleteTemplateGroupRes[0].message : "Template group not found for deletion"
});
steps.push(...cleanupSteps);
}
return {
success,
message: success ? "Smoketest passed successfully" : "Smoketest failed",
steps
};
}
}

View file

@ -54,6 +54,10 @@ export interface CreateHost {
location?: InputMaybe<LocationInput>;
/** Optional display name of the device (must be unique if provided - default is to set display name to deviceKey). */
name?: InputMaybe<Scalars['String']['input']>;
/** List of template names to link to the host. */
templateNames?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
/** List of template IDs to link to the host. */
templateids?: InputMaybe<Array<InputMaybe<Scalars['Int']['input']>>>;
}
/** Input for creating or identifying a host group. */
@ -478,6 +482,18 @@ export interface Mutation {
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
createHost?: Maybe<CreateHostResponse>;
/**
* Delete host groups by their IDs or by a name pattern.
*
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
deleteHostGroups?: Maybe<Array<DeleteResponse>>;
/**
* Delete hosts by their IDs or by a name pattern.
*
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
deleteHosts?: Maybe<Array<DeleteResponse>>;
/**
* Delete template groups by their IDs or by a name pattern.
*
@ -528,6 +544,8 @@ export interface Mutation {
* Authentication: Requires `zbx_session` cookie or `zabbix-auth-token` header.
*/
importUserRights?: Maybe<ImportUserRightsResult>;
/** Runs a smoketest: creates a template, links a host, verifies it, and cleans up. */
runSmoketest: SmoketestResponse;
}
@ -535,7 +553,20 @@ export interface MutationCreateHostArgs {
host: Scalars['String']['input'];
hostgroupids: Array<Scalars['Int']['input']>;
location?: InputMaybe<LocationInput>;
templateids: Array<Scalars['Int']['input']>;
templateNames?: InputMaybe<Array<InputMaybe<Scalars['String']['input']>>>;
templateids?: InputMaybe<Array<InputMaybe<Scalars['Int']['input']>>>;
}
export interface MutationDeleteHostGroupsArgs {
groupids?: InputMaybe<Array<Scalars['Int']['input']>>;
name_pattern?: InputMaybe<Scalars['String']['input']>;
}
export interface MutationDeleteHostsArgs {
hostids?: InputMaybe<Array<Scalars['Int']['input']>>;
name_pattern?: InputMaybe<Scalars['String']['input']>;
}
@ -576,6 +607,13 @@ export interface MutationImportUserRightsArgs {
input: UserRightsInput;
}
export interface MutationRunSmoketestArgs {
groupName: Scalars['String']['input'];
hostName: Scalars['String']['input'];
templateName: Scalars['String']['input'];
}
/** Operational data common to most devices. */
export interface OperationalDeviceData {
__typename?: 'OperationalDeviceData';
@ -744,6 +782,28 @@ export interface QueryUserPermissionsArgs {
objectNames?: InputMaybe<Array<Scalars['String']['input']>>;
}
/** Response object for the smoketest operation. */
export interface SmoketestResponse {
__typename?: 'SmoketestResponse';
/** Overall status message. */
message?: Maybe<Scalars['String']['output']>;
/** Detailed results for each step. */
steps: Array<SmoketestStep>;
/** True if all steps of the smoketest succeeded. */
success: Scalars['Boolean']['output'];
}
/** Results for a single step in the smoketest. */
export interface SmoketestStep {
__typename?: 'SmoketestStep';
/** Status message or error message for the step. */
message?: Maybe<Scalars['String']['output']>;
/** Name of the step (e.g. 'Create Template'). */
name: Scalars['String']['output'];
/** True if the step succeeded. */
success: Scalars['Boolean']['output'];
}
export enum SortOrder {
/** Deliver values in ascending order */
Asc = 'asc',
@ -1155,6 +1215,8 @@ export type ResolversTypes = {
Permission: Permission;
PermissionRequest: PermissionRequest;
Query: ResolverTypeWrapper<{}>;
SmoketestResponse: ResolverTypeWrapper<SmoketestResponse>;
SmoketestStep: ResolverTypeWrapper<SmoketestStep>;
SortOrder: SortOrder;
StorageItemType: StorageItemType;
String: ResolverTypeWrapper<Scalars['String']['output']>;
@ -1227,6 +1289,8 @@ export type ResolversParentTypes = {
OperationalDeviceData: OperationalDeviceData;
PermissionRequest: PermissionRequest;
Query: {};
SmoketestResponse: SmoketestResponse;
SmoketestStep: SmoketestStep;
String: Scalars['String']['output'];
Template: Template;
Time: Scalars['Time']['output'];
@ -1449,7 +1513,9 @@ export type LocationResolvers<ContextType = any, ParentType extends ResolversPar
};
export type MutationResolvers<ContextType = any, ParentType extends ResolversParentTypes['Mutation'] = ResolversParentTypes['Mutation']> = {
createHost?: Resolver<Maybe<ResolversTypes['CreateHostResponse']>, ParentType, ContextType, RequireFields<MutationCreateHostArgs, 'host' | 'hostgroupids' | 'templateids'>>;
createHost?: Resolver<Maybe<ResolversTypes['CreateHostResponse']>, ParentType, ContextType, RequireFields<MutationCreateHostArgs, 'host' | 'hostgroupids'>>;
deleteHostGroups?: Resolver<Maybe<Array<ResolversTypes['DeleteResponse']>>, ParentType, ContextType, Partial<MutationDeleteHostGroupsArgs>>;
deleteHosts?: Resolver<Maybe<Array<ResolversTypes['DeleteResponse']>>, ParentType, ContextType, Partial<MutationDeleteHostsArgs>>;
deleteTemplateGroups?: Resolver<Maybe<Array<ResolversTypes['DeleteResponse']>>, ParentType, ContextType, Partial<MutationDeleteTemplateGroupsArgs>>;
deleteTemplates?: Resolver<Maybe<Array<ResolversTypes['DeleteResponse']>>, ParentType, ContextType, Partial<MutationDeleteTemplatesArgs>>;
importHostGroups?: Resolver<Maybe<Array<ResolversTypes['CreateHostGroupResponse']>>, ParentType, ContextType, RequireFields<MutationImportHostGroupsArgs, 'hostGroups'>>;
@ -1457,6 +1523,7 @@ export type MutationResolvers<ContextType = any, ParentType extends ResolversPar
importTemplateGroups?: Resolver<Maybe<Array<ResolversTypes['CreateTemplateGroupResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplateGroupsArgs, 'templateGroups'>>;
importTemplates?: Resolver<Maybe<Array<ResolversTypes['ImportTemplateResponse']>>, ParentType, ContextType, RequireFields<MutationImportTemplatesArgs, 'templates'>>;
importUserRights?: Resolver<Maybe<ResolversTypes['ImportUserRightsResult']>, ParentType, ContextType, RequireFields<MutationImportUserRightsArgs, 'dryRun' | 'input'>>;
runSmoketest?: Resolver<ResolversTypes['SmoketestResponse'], ParentType, ContextType, RequireFields<MutationRunSmoketestArgs, 'groupName' | 'hostName' | 'templateName'>>;
};
export type OperationalDeviceDataResolvers<ContextType = any, ParentType extends ResolversParentTypes['OperationalDeviceData'] = ResolversParentTypes['OperationalDeviceData']> = {
@ -1488,6 +1555,20 @@ export type QueryResolvers<ContextType = any, ParentType extends ResolversParent
zabbixVersion?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
};
export type SmoketestResponseResolvers<ContextType = any, ParentType extends ResolversParentTypes['SmoketestResponse'] = ResolversParentTypes['SmoketestResponse']> = {
message?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
steps?: Resolver<Array<ResolversTypes['SmoketestStep']>, ParentType, ContextType>;
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type SmoketestStepResolvers<ContextType = any, ParentType extends ResolversParentTypes['SmoketestStep'] = ResolversParentTypes['SmoketestStep']> = {
message?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
success?: Resolver<ResolversTypes['Boolean'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
};
export type StorageItemTypeResolvers = EnumResolverSignature<{ FLOAT?: any, INT?: any, TEXT?: any }, ResolversTypes['StorageItemType']>;
export type TemplateResolvers<ContextType = any, ParentType extends ResolversParentTypes['Template'] = ResolversParentTypes['Template']> = {
@ -1636,6 +1717,8 @@ export type Resolvers<ContextType = any> = {
OperationalDeviceData?: OperationalDeviceDataResolvers<ContextType>;
Permission?: PermissionResolvers;
Query?: QueryResolvers<ContextType>;
SmoketestResponse?: SmoketestResponseResolvers<ContextType>;
SmoketestStep?: SmoketestStepResolvers<ContextType>;
StorageItemType?: StorageItemTypeResolvers;
Template?: TemplateResolvers<ContextType>;
Time?: GraphQLScalarType;

View file

@ -71,8 +71,8 @@ describe("HostImporter", () => {
// Mocking template lookup for deviceType
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce([{ templateid: "301" }]);
// Mocking host.create via requestByPath
(zabbixAPI.requestByPath as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
// Mocking host.create via post (called by ZabbixCreateHostRequest)
(zabbixAPI.post as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
const result = await HostImporter.importHosts(hosts, "token");

View file

@ -62,9 +62,8 @@ describe("Host Integration Tests", () => {
(zabbixAPI.post as jest.Mock)
.mockResolvedValueOnce([{ groupid: "201", name: ZABBIX_EDGE_DEVICE_BASE_GROUP }]) // Base group
.mockResolvedValueOnce([{ groupid: "202", name: ZABBIX_EDGE_DEVICE_BASE_GROUP + "/ConstructionSite/Test" }]) // Specific group
.mockResolvedValueOnce([{ templateid: "301" }]); // Template lookup
(zabbixAPI.requestByPath as jest.Mock).mockResolvedValueOnce({ hostids: ["401"] });
.mockResolvedValueOnce([{ templateid: "301" }]) // Template lookup
.mockResolvedValueOnce({ hostids: ["401"] }); // Host creation
const response = await server.executeOperation({
query: mutation,

View file

@ -0,0 +1,95 @@
import {ApolloServer} from '@apollo/server';
import {schema_loader} from '../api/schema.js';
import {zabbixAPI} from '../datasources/zabbix-api.js';
// Mocking ZabbixAPI
jest.mock("../datasources/zabbix-api.js", () => ({
zabbixAPI: {
post: jest.fn(),
executeRequest: jest.fn(),
baseURL: 'http://localhost/zabbix',
requestByPath: jest.fn()
},
ZABBIX_EDGE_DEVICE_BASE_GROUP: "Roadwork"
}));
describe("Template Linking Tests", () => {
let server: ApolloServer;
beforeAll(async () => {
const schema = await schema_loader();
server = new ApolloServer({
schema,
});
});
test("createHost with templateNames", async () => {
const mutation = `
mutation CreateHost($host: String!, $hostgroupids: [Int!]!, $templateNames: [String!]!) {
createHost(host: $host, hostgroupids: $hostgroupids, templateNames: $templateNames) {
hostids
}
}
`;
const variables = {
host: "TestHost",
hostgroupids: [1],
templateNames: ["Test Template"]
};
(zabbixAPI.post as jest.Mock)
.mockResolvedValueOnce([{ templateid: "101", name: "Test Template" }]) // Template lookup
.mockResolvedValueOnce({ hostids: ["201"] }); // Host creation
const response = await server.executeOperation({
query: mutation,
variables: variables,
}, {
contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
});
expect(response.body.kind).toBe('single');
// @ts-ignore
const result = response.body.singleResult;
expect(result.errors).toBeUndefined();
expect(result.data.createHost.hostids).toContain(201);
});
test("importHosts with templateids and templateNames", async () => {
const mutation = `
mutation ImportHosts($hosts: [CreateHost!]!) {
importHosts(hosts: $hosts) {
hostid
}
}
`;
const variables = {
hosts: [{
deviceKey: "TestDevice",
deviceType: "TestType",
groupNames: ["TestGroup"],
templateids: [101],
templateNames: ["Another Template"]
}]
};
(zabbixAPI.post as jest.Mock)
.mockResolvedValueOnce([{ groupid: "501", name: "Roadwork" }]) // Base group lookup
.mockResolvedValueOnce([{ groupid: "502", name: "Roadwork/TestGroup" }]) // Specific group lookup
.mockResolvedValueOnce([{ templateid: "102", name: "Another Template" }]) // Template lookup
.mockResolvedValueOnce({ hostids: ["202"] }); // Host creation
const response = await server.executeOperation({
query: mutation,
variables: variables,
}, {
contextValue: { zabbixAuthToken: 'test-token', dataSources: { zabbixAPI: zabbixAPI } }
});
expect(response.body.kind).toBe('single');
// @ts-ignore
const result = response.body.singleResult;
expect(result.errors).toBeUndefined();
expect(result.data.importHosts[0].hostid).toBe("202");
});
});