Skip to content

Notify android devices of certificate_template changes - #36258

Merged
ksykulev merged 13 commits into
mainfrom
35465-deploy-certs
Dec 1, 2025
Merged

Notify android devices of certificate_template changes#36258
ksykulev merged 13 commits into
mainfrom
35465-deploy-certs

Conversation

@ksykulev

@ksykulev ksykulev commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

Related issue: Resolves #35465

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.
  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements)

Testing

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

Release Notes

  • New Features
    • Added certificate template delivery support for MDM-enrolled Android devices via managed configurations.
    • Enhanced certificate templates with delivery status, challenge information, and certificate authority type details.
    • Improved host certificate template management with expanded database operations for lookup, insertion, and status updates.

✏️ Tip: You can customize this high-level summary in your review settings.

@codecov

codecov Bot commented Nov 25, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.77320% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 66.01%. Comparing base (300c263) to head (dc4c7e2).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
server/mdm/android/service/profiles.go 65.06% 15 Missing and 14 partials ⚠️
...rver/datastore/mysql/host_certificate_templates.go 80.48% 8 Missing and 8 partials ⚠️
server/datastore/mysql/certificate_templates.go 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #36258      +/-   ##
==========================================
+ Coverage   65.99%   66.01%   +0.02%     
==========================================
  Files        2136     2138       +2     
  Lines      182057   182213     +156     
  Branches     7594     7489     -105     
==========================================
+ Hits       120155   120296     +141     
+ Misses      50913    50907       -6     
- Partials    10989    11010      +21     
Flag Coverage Δ
backend 67.61% <75.77%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ksykulev
ksykulev marked this pull request as ready for review November 25, 2025 22:34
@ksykulev
ksykulev requested a review from a team as a code owner November 25, 2025 22:34
@getvictor

Copy link
Copy Markdown
Member

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Nov 26, 2025

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented Nov 26, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

Adds infrastructure to deliver certificate templates to MDM-enrolled Android devices. Introduces new datastore methods to list Android hosts awaiting certificate templates, bulk-insert host-template associations, and update delivery status. Extends certificate template response types with delivery status fields and integrates certificate template reconciliation into the Android profile synchronization workflow.

Changes

Cohort / File(s) Summary
Changelog
changes/35465-send-certificate-templates
Adds release note documenting MDM-enrolled Android devices receive certificate templates via managed configurations.
Domain types
server/fleet/certificate_templates.go, server/fleet/host_certificate_template.go
Adds new structs HostCertificateTemplate and CertificateTemplateForHost; extends CertificateTemplateResponseFull with Status, SCEPChallenge, FleetChallenge, SCEPChallengeEncrypted, and CertificateAuthorityType fields.
Datastore interface
server/fleet/datastore.go
Adds three public interface methods: ListAndroidHostUUIDsWithCertificateTemplates, ListCertificateTemplatesForHosts, BulkInsertHostCertificateTemplates.
MySQL datastore implementation
server/datastore/mysql/certificate_templates.go
Extends certificate template retrieval with LEFT JOIN to host_certificate_templates; adds decryption of SCEPChallengeEncrypted for pending templates; removes UpdateCertificateStatus function.
MySQL host certificate templates
server/datastore/mysql/host_certificate_templates.go
Introduces new file with four public methods: ListAndroidHostUUIDsWithCertificateTemplates, ListCertificateTemplatesForHosts, BulkInsertHostCertificateTemplates, UpdateCertificateStatus.
Datastore tests
server/datastore/mysql/certificate_templates_test.go, server/datastore/mysql/host_certificate_templates_test.go
Updates certificate template tests with new status/challenge assertions; removes UpdateCertificateStatus test; adds comprehensive host certificate templates test suite covering listing, bulk insertion, and status updates.
Android MDM models
server/mdm/android/android.go
Introduces AgentCertificateTemplate type; adds CertificateTemplateIDs field to AgentManagedConfiguration; updates JSON field tags for ServerURL, HostUUID, EnrollSecret from camelCase to snake_case.
Android profile reconciliation
server/mdm/android/service/profiles.go
Adds certificate template reconciliation logic pre-processing in ReconcileProfiles; introduces reconcileCertificateTemplates and processCertificateTemplateBatch helpers to fetch templates, generate fleet challenges, update policies, and bulk-insert records.
Android service tests
server/mdm/android/service/profiles_test.go
Adds testCertificateTemplates test case validating certificate template delivery, Fleet Agent policy updates, and host record creation.
Mock datastore
server/mock/datastore_mock.go
Adds new function types and DataStore fields/methods for the three new datastore operations with invocation tracking.
Integration tests
server/service/integration_core_test.go
Formats SQL insertion string in TestUpdateHostCertificateTemplate; no behavioral changes.

Sequence Diagram(s)

sequenceDiagram
    participant RP as ReconcileProfiles
    participant CT as reconcileCertificateTemplates
    participant DS as Datastore
    participant PCB as processCertificateTemplateBatch
    participant PP as AddFleetAgentToAndroidPolicy
    participant DB as Database

    RP->>CT: reconcileCertificateTemplates(ctx)
    Note over CT: Process in batches of 1000
    CT->>DS: ListAndroidHostUUIDsWithCertificateTemplates(offset, limit)
    DS->>DB: Query Android hosts lacking cert templates
    DB-->>DS: Return host UUIDs
    DS-->>CT: Return batch of host UUIDs
    
    CT->>DS: ListCertificateTemplatesForHosts(hostUUIDs)
    DS->>DB: Query templates for hosts
    DB-->>DS: Return templates with status/challenges
    DS-->>CT: Return CertificateTemplateForHost records

    CT->>PCB: processCertificateTemplateBatch(templates)
    Note over PCB: Detect new certs (no FleetChallenge)
    PCB->>PCB: Generate FleetChallenge for new certs
    PCB->>PCB: Group templates by host
    
    PCB->>PP: AddFleetAgentToAndroidPolicy(policy, config)
    Note over PP: Config includes CertificateTemplateIDs
    PP-->>PCB: Policy updated
    
    PCB->>DS: BulkInsertHostCertificateTemplates(records)
    DS->>DB: Insert host_certificate_templates rows
    DB-->>DS: Rows inserted
    DS-->>PCB: Success
    
    PCB-->>CT: Batch complete
    CT->>CT: Move to next batch if more hosts
    CT-->>RP: All batches processed
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

  • Areas requiring extra attention:
    • server/datastore/mysql/certificate_templates.go: Decryption logic for SCEPChallengeEncrypted and conditional field clearing; removal of UpdateCertificateStatus function and migration of its logic.
    • server/datastore/mysql/host_certificate_templates.go: Validation and error handling paths for new datastore operations; SQL query construction with dynamic placeholders.
    • server/mdm/android/service/profiles.go: Certificate template reconciliation flow and integration with existing profile reconciliation; batch processing logic and dependency on external API calls.
    • server/mdm/android/android.go: JSON tag changes (camelCase to snake_case) for AgentManagedConfiguration fields; verify compatibility with existing integrations.

Possibly related PRs

Suggested reviewers

  • dantecatalfamo
  • sgress454

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.33% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly describes the main change: notifying Android devices of certificate template changes, which aligns with the PR's core functionality.
Description check ✅ Passed Description covers required checklist items including changes file, data validation, and test additions, though manual QA is not yet completed.
Linked Issues check ✅ Passed The PR implements key requirements from #35465: sends notifications to devices about certificate deployment, runs within the existing ReconcileProfiles job, updates delivery status (via host_certificate_templates table), and includes comprehensive datastore methods and tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to certificate template delivery: new datastore methods, Android managed configuration updates, reconciliation logic, and comprehensive tests. No unrelated or out-of-scope changes detected.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 35465-deploy-certs

Tip

📝 Customizable high-level summaries are now available in beta!

You can now customize how CodeRabbit generates the high-level summary in your pull requests — including its content, structure, tone, and formatting.

  • Provide your own instructions using the high_level_summary_instructions setting.
  • Format the summary however you like (bullet lists, tables, multi-section layouts, contributor stats, etc.).
  • Use high_level_summary_in_walkthrough to move the summary from the description to the walkthrough section.

Example instruction:

"Divide the high-level summary into five sections:

  1. 📝 Description — Summarize the main change in 50–60 words, explaining what was done.
  2. 📓 References — List relevant issues, discussions, documentation, or related PRs.
  3. 📦 Dependencies & Requirements — Mention any new/updated dependencies, environment variable changes, or configuration updates.
  4. 📊 Contributor Summary — Include a Markdown table showing contributions:
    | Contributor | Lines Added | Lines Removed | Files Changed |
  5. ✔️ Additional Notes — Add any extra reviewer context.
    Keep each section concise (under 200 words) and use bullet or numbered lists for clarity."

Note: This feature is currently in beta for Pro-tier users, and pricing will be announced later.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/datastore/mysql/certificate_templates.go (1)

14-36: Query may return non-deterministic results with multiple host associations.

The LEFT JOIN host_certificate_templates can return multiple rows when a certificate template is associated with multiple hosts. Since sqlx.GetContext expects exactly one row, this will either fail or return an arbitrary row.

If the intent is to fetch template details without host-specific data, consider removing the join. If host-specific data is needed, the query should filter by host_uuid or use LIMIT 1 with an explicit ordering.

 		LEFT JOIN host_certificate_templates
 			ON host_certificate_templates.certificate_template_id = certificate_templates.id
-		WHERE certificate_templates.id = ?
+		WHERE certificate_templates.id = ?
+		LIMIT 1

Or if this endpoint should not include host-specific status:

-		LEFT JOIN host_certificate_templates
-			ON host_certificate_templates.certificate_template_id = certificate_templates.id
 		WHERE certificate_templates.id = ?

As per coding guidelines, ensure queries return precise results when intended for a specific entity.

🧹 Nitpick comments (7)
server/mdm/android/service/profiles_test.go (1)

888-890: Consider using t.Setenv for cleaner environment variable handling.

Go's testing.T.Setenv automatically restores the original value when the test completes, eliminating the need for manual defer cleanup.

-	oldEnvValue := os.Getenv("FLEET_DEV_ANDROID_AGENT_PACKAGE")
-	os.Setenv("FLEET_DEV_ANDROID_AGENT_PACKAGE", "com.fleetdm.agent")
-	defer os.Setenv("FLEET_DEV_ANDROID_AGENT_PACKAGE", oldEnvValue)
+	t.Setenv("FLEET_DEV_ANDROID_AGENT_PACKAGE", "com.fleetdm.agent")

This also handles the case where Setenv fails (it will fail the test automatically).

server/mdm/android/service/profiles.go (2)

590-602: N+1 query pattern: Consider batching database calls.

For each host in hostsNeedingUpdate, two separate database calls are made (AndroidHostLiteByHostUUID and GetEnrollSecrets). With many hosts, this could significantly impact performance.

Consider fetching all required host data and enroll secrets in batch queries before the loop.


619-625: Consider extracting Service instantiation.

Creating a new Service struct inline to call AddFleetAgentToAndroidPolicy is unusual. If profileReconciler already has access to r.DS and r.Client, consider either:

  1. Adding the method directly to profileReconciler
  2. Injecting the service at construction time
server/fleet/host_certificate_template.go (1)

9-10: Consider using time.Time for timestamp fields.

CreatedAt and UpdatedAt are defined as string but typically timestamp fields use time.Time for type safety and easier manipulation. If the database returns these as strings and you want to avoid parsing, this is acceptable, but consider whether time.Time would be more consistent with other types in the codebase.

server/datastore/mysql/host_certificate_templates_test.go (2)

518-519: Variable shadowing: uuid shadows the imported package.

The variable uuid shadows the imported uuid package, which could cause confusion.

-	nodeKey := uuid.New().String()
-	uuid := uuid.New().String()
+	nodeKey := uuid.New().String()
+	hostUUID := uuid.New().String()

Then update references from uuid to hostUUID throughout the function.


278-278: Closure variable shared across test cases.

templateWithHostRecordId is declared at the function scope and modified in the before closure of one test case, then read in the testFunc closure. While this works because tests run sequentially with TruncateTables cleanup, it's somewhat fragile. Consider scoping this variable within the specific test case or using a different pattern.

server/datastore/mysql/host_certificate_templates.go (1)

73-105: Bulk insert is safe; consider behavior on duplicates/races

The multi-row INSERT is parameterized correctly and avoids injection risk, and the early return on empty input is good. If there is (or will be) a unique constraint on (host_uuid, certificate_template_id), be aware this will fail the whole batch on duplicates (e.g., if a reconcile job races with itself); consider INSERT ... ON DUPLICATE KEY UPDATE or INSERT IGNORE if idempotent behavior is desired.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0d5bebb and d6ddd69.

📒 Files selected for processing (13)
  • changes/35465-send-certificate-templates (1 hunks)
  • server/datastore/mysql/certificate_templates.go (2 hunks)
  • server/datastore/mysql/certificate_templates_test.go (1 hunks)
  • server/datastore/mysql/host_certificate_templates.go (1 hunks)
  • server/datastore/mysql/host_certificate_templates_test.go (1 hunks)
  • server/fleet/certificate_templates.go (1 hunks)
  • server/fleet/datastore.go (1 hunks)
  • server/fleet/host_certificate_template.go (1 hunks)
  • server/mdm/android/android.go (1 hunks)
  • server/mdm/android/service/profiles.go (2 hunks)
  • server/mdm/android/service/profiles_test.go (3 hunks)
  • server/mock/datastore_mock.go (3 hunks)
  • server/service/integration_core_test.go (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
**/*.go

⚙️ CodeRabbit configuration file

When reviewing SQL queries that are added or modified, ensure that appropriate filtering criteria are applied—especially when a query is intended to return data for a specific entity (e.g., a single host). Check for missing WHERE clauses or incorrect filtering that could lead to incorrect or non-deterministic results (e.g., returning the first row instead of the correct one). Flag any queries that may return unintended results due to lack of precise scoping.

Files:

  • server/fleet/host_certificate_template.go
  • server/datastore/mysql/certificate_templates.go
  • server/mdm/android/service/profiles.go
  • server/datastore/mysql/host_certificate_templates.go
  • server/mdm/android/android.go
  • server/service/integration_core_test.go
  • server/datastore/mysql/host_certificate_templates_test.go
  • server/datastore/mysql/certificate_templates_test.go
  • server/fleet/datastore.go
  • server/mock/datastore_mock.go
  • server/mdm/android/service/profiles_test.go
  • server/fleet/certificate_templates.go
🧠 Learnings (9)
📚 Learning: 2025-07-08T16:06:54.576Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 30589
File: ee/server/service/hostidentity/depot/depot.go:104-119
Timestamp: 2025-07-08T16:06:54.576Z
Learning: In ee/server/service/hostidentity/depot/depot.go, the security concern where shared challenges allow certificate revocation (lines 104-119) is a known issue that will be addressed in a later feature, not an immediate concern to fix.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
  • server/datastore/mysql/certificate_templates_test.go
📚 Learning: 2025-08-08T07:40:05.301Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 31726
File: server/datastore/mysql/labels_test.go:2031-2031
Timestamp: 2025-08-08T07:40:05.301Z
Learning: In fleetdm/fleet repository tests (server/datastore/mysql/labels_test.go and similar), using testing.T.Context() is valid because the project targets a recent Go version where testing.T.Context() exists. Do not suggest replacing t.Context() with context.Background() in this codebase.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
  • server/datastore/mysql/host_certificate_templates_test.go
  • server/datastore/mysql/certificate_templates_test.go
  • server/mdm/android/service/profiles_test.go
📚 Learning: 2025-08-08T07:40:05.301Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 31726
File: server/datastore/mysql/labels_test.go:2031-2031
Timestamp: 2025-08-08T07:40:05.301Z
Learning: Fleet repo targets Go 1.24.5 (root go.mod), which supports testing.T.Context(). Do not flag usage of t.Context() or suggest replacing it with context.Background() in tests (e.g., server/datastore/mysql/labels_test.go Line 2031 and similar).

Applied to files:

  • server/datastore/mysql/certificate_templates.go
  • server/datastore/mysql/certificate_templates_test.go
  • server/mdm/android/service/profiles_test.go
📚 Learning: 2025-08-13T18:20:42.136Z
Learnt from: titanous
Repo: fleetdm/fleet PR: 31075
File: tools/redis-tests/elasticache/iam_auth.go:4-10
Timestamp: 2025-08-13T18:20:42.136Z
Learning: For test harnesses and CLI tools in the Fleet codebase, resource cleanup on error paths (like closing connections before log.Fatalf) may not be necessary since the OS handles cleanup when the process exits. These tools prioritize simplicity over defensive programming patterns used in production code.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
  • server/datastore/mysql/certificate_templates_test.go
📚 Learning: 2025-08-08T07:40:05.301Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 31726
File: server/datastore/mysql/labels_test.go:2031-2031
Timestamp: 2025-08-08T07:40:05.301Z
Learning: In fleetdm/fleet, tests may validly use testing.T.Context() when the module/toolchain targets Go 1.24+. Do not flag t.Context() usage in this codebase if go.mod/toolchain indicates Go >= 1.24.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
  • server/mdm/android/service/profiles_test.go
📚 Learning: 2025-07-08T16:12:48.797Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 30589
File: ee/server/service/hostidentity/depot/depot.go:108-111
Timestamp: 2025-07-08T16:12:48.797Z
Learning: In ee/server/service/hostidentity/depot/depot.go, the SCEP depot interface methods like Put() do not accept context parameters, and the common_mysql.WithRetryTxx callback function type TxFn only receives a transaction parameter, not a context. Therefore, using context.Background() in tx.ExecContext calls within the transaction callback is the correct approach.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
📚 Learning: 2025-08-22T01:14:05.454Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 32173
File: server/datastore/mysql/policies.go:360-365
Timestamp: 2025-08-22T01:14:05.454Z
Learning: The sqlx library in Go can handle pointer types as query parameters and will automatically dereference them when passing to the underlying database/sql driver. Unlike raw database/sql which doesn't accept pointer types, sqlx intelligently handles both pointer and non-pointer types for query arguments.

Applied to files:

  • server/datastore/mysql/certificate_templates.go
📚 Learning: 2025-11-25T00:42:41.117Z
Learnt from: getvictor
Repo: fleetdm/fleet PR: 36233
File: android/app/src/main/java/com/fleetdm/agent/RoleNotificationReceiverService.kt:18-23
Timestamp: 2025-11-25T00:42:41.117Z
Learning: For Android Management API NotificationReceiverService integration, the SERVICE_APP_ROLES meta-data must have an empty string value (android:value="") in the AndroidManifest.xml. The actual app roles (e.g., COMPANION_APP) are assigned via ApplicationPolicy.roles in the MDM policy payload, not in the manifest meta-data.

Applied to files:

  • changes/35465-send-certificate-templates
📚 Learning: 2025-10-03T18:16:11.482Z
Learnt from: MagnusHJensen
Repo: fleetdm/fleet PR: 33805
File: server/service/integration_mdm_test.go:1248-1251
Timestamp: 2025-10-03T18:16:11.482Z
Learning: In server/service/integration_mdm_test.go, the helper createAppleMobileHostThenEnrollMDM(platform string) is exclusively for iOS/iPadOS hosts (mobile). Do not flag macOS model/behavior issues based on changes within this helper; macOS provisioning uses different helpers such as createHostThenEnrollMDM.

Applied to files:

  • server/mdm/android/android.go
  • server/datastore/mysql/certificate_templates_test.go
🧬 Code graph analysis (10)
server/fleet/host_certificate_template.go (1)
server/fleet/mdm.go (1)
  • MDMDeliveryStatus (445-445)
server/datastore/mysql/certificate_templates.go (3)
server/contexts/ctxerr/ctxerr.go (1)
  • Wrap (199-202)
server/fleet/mdm.go (1)
  • MDMDeliveryPending (486-486)
server/ptr/ptr.go (1)
  • String (10-12)
server/mdm/android/service/profiles.go (5)
server/contexts/ctxerr/ctxerr.go (2)
  • Wrap (199-202)
  • Wrapf (211-214)
server/fleet/host_certificate_template.go (1)
  • HostCertificateTemplate (3-11)
server/fleet/mdm.go (1)
  • MDMDeliveryPending (486-486)
server/mdm/android/android.go (2)
  • AgentManagedConfiguration (52-57)
  • AgentCertificateTemplate (59-61)
server/fleet/hosts.go (1)
  • Host (276-405)
server/datastore/mysql/host_certificate_templates.go (3)
server/fleet/datastore.go (1)
  • Datastore (50-2524)
server/fleet/host_certificate_template.go (2)
  • CertificateTemplateForHost (13-18)
  • HostCertificateTemplate (3-11)
server/fleet/mdm.go (1)
  • MDMDeliveryStatus (445-445)
server/mdm/android/android.go (1)
server/fleet/app.go (1)
  • EnrollSecret (1367-1375)
server/datastore/mysql/host_certificate_templates_test.go (5)
server/datastore/mysql/testing_utils.go (2)
  • CreateMySQLDS (406-408)
  • ExecAdhocSQL (420-424)
server/fleet/datastore.go (1)
  • Datastore (50-2524)
server/fleet/certificate_templates.go (1)
  • CertificateTemplate (10-15)
server/fleet/mdm.go (3)
  • MDMDeliveryPending (486-486)
  • MDMDeliveryVerified (484-484)
  • MDMDeliveryStatus (445-445)
server/fleet/host_certificate_template.go (1)
  • HostCertificateTemplate (3-11)
server/datastore/mysql/certificate_templates_test.go (3)
server/ptr/ptr.go (2)
  • String (10-12)
  • T (76-78)
server/fleet/certificate_templates.go (1)
  • CertificateTemplate (10-15)
server/fleet/mdm.go (2)
  • MDMDeliveryPending (486-486)
  • MDMDeliveryVerifying (485-485)
server/fleet/datastore.go (1)
server/fleet/host_certificate_template.go (2)
  • CertificateTemplateForHost (13-18)
  • HostCertificateTemplate (3-11)
server/mock/datastore_mock.go (1)
server/fleet/host_certificate_template.go (2)
  • CertificateTemplateForHost (13-18)
  • HostCertificateTemplate (3-11)
server/fleet/certificate_templates.go (1)
server/fleet/mdm.go (1)
  • MDMDeliveryStatus (445-445)
🔇 Additional comments (20)
server/service/integration_core_test.go (1)

14677-14683: LGTM — formatting-only SQL change

Placeholders and explicit columns keep the insert safe and deterministic for the test. No functional impact.

server/fleet/certificate_templates.go (1)

29-38: LGTM! Struct extensions for certificate template delivery.

The new fields properly support the certificate delivery workflow:

  • Status as a pointer correctly handles nullable database values
  • SCEPChallengeEncrypted is appropriately excluded from JSON serialization with json:"-"
  • Proper use of MDMDeliveryStatus type from server/fleet/mdm.go
changes/35465-send-certificate-templates (1)

1-1: LGTM! Clear changelog entry.

The message accurately describes the user-visible change.

server/datastore/mysql/certificate_templates.go (1)

38-50: Verify intentional nullification of FleetChallenge from query result.

The else branch explicitly sets FleetChallenge to nil, overwriting any value retrieved from the database. This appears intentional for security (hiding challenges when not pending), but verify this is the desired behavior since FleetChallenge is populated from the query result at line 28.

server/datastore/mysql/certificate_templates_test.go (2)

203-266: Good test coverage for pending status with challenge decryption.

The test properly verifies:

  • Status is MDMDeliveryPending
  • FleetChallenge contains the expected value
  • SCEPChallenge is decrypted from the CA's encrypted challenge

267-330: Good test coverage for verifying status with cleared challenges.

The test correctly verifies that when status is MDMDeliveryVerifying, both FleetChallenge and SCEPChallenge are nil, confirming the post-processing logic in the datastore.

server/mdm/android/service/profiles_test.go (2)

797-870: Good test setup for certificate template reconciliation.

The test properly:

  • Creates necessary entities (team, CA, templates, hosts)
  • Pre-populates host_certificate_templates for host2 to test exclusion logic
  • Sets up app config with server URL

892-948: Thorough reconciliation and idempotency testing.

The test correctly verifies:

  • API calls target the expected host (host1, not host2)
  • Managed configuration contains the expected certificate template IDs
  • Database records are created with pending status and fleet challenges
  • Re-running reconciliation doesn't create duplicates or trigger unnecessary API calls
server/mdm/android/android.go (2)

53-57: Breaking change: JSON field tags renamed from camelCase to snake_case.

The JSON tags changed from serverURL/hostUUID/enrollSecret to server_url/host_uuid/enroll_secret. If any Android client or agent already consumes this configuration with the old field names, this will break deserialization on those clients.

Please verify that the Android agent code is updated simultaneously to expect snake_case, or that no deployed agents rely on the previous format.


59-61: LGTM!

The new AgentCertificateTemplate type is appropriately minimal for carrying template identifiers to the Android agent.

server/mdm/android/service/profiles.go (2)

78-80: LGTM!

Certificate template reconciliation is appropriately placed before profile application, ensuring templates are processed first.


532-538: Verify pagination logic doesn't miss records.

The pagination relies on a stable ordering and no concurrent modifications. If hosts are added or removed between batches, some hosts could be missed or processed twice. Given that this runs in a cron job context, verify this is acceptable for the use case.

server/fleet/host_certificate_template.go (1)

13-18: LGTM!

Using pointer types for FleetChallenge and Status appropriately handles NULL values from LEFT JOIN queries.

server/fleet/datastore.go (1)

2513-2518: LGTM!

The new datastore methods follow existing interface patterns and are well-documented with concise comments describing their purpose.

server/datastore/mysql/host_certificate_templates_test.go (1)

36-273: Good test coverage for various scenarios.

The test cases cover key scenarios: hosts with no templates, hosts with existing templates, non-Android platforms, and hosts not enrolled in MDM. The use of TruncateTables ensures proper test isolation.

server/datastore/mysql/host_certificate_templates.go (2)

13-38: Android host UUID query and pagination look sound

The query correctly scopes to Android, MDM-enrolled hosts that are missing corresponding host_certificate_templates rows and orders with LIMIT/OFFSET, so the pagination will be stable and deterministic.


40-71: Host certificate template listing is consistent with data model

Empty input is handled early, the IN clause is correctly parameterized over host UUIDs, and the selected columns match fleet.CertificateTemplateForHost (including nullable fleet_challenge and status when no row exists), so this read path looks correct.

server/mock/datastore_mock.go (3)

1630-1634: New mock function types are consistent and correctly typed

The three new function types follow the existing conventions (context first, specific args, typed return values) and align with the HostCertificateTemplate / CertificateTemplateForHost types and pagination needs. No issues from a mock API or typing perspective.


4047-4054: Struct fields integrate cleanly with existing invocation-tracking pattern

The added DataStore fields mirror the established pattern: one Func and one FuncInvoked bool per method, with naming consistent with the type aliases. This keeps the mock configuration and call tracking coherent with the rest of the file.


9679-9698: Mock methods correctly wrap and delegate to underlying funcs

Each new method:

  • Locks only long enough to set the *Invoked flag.
  • Calls the corresponding *Func outside the lock.
  • Preserves the intended signatures and argument order.

This matches the pattern used throughout the file and should work as expected in tests.

Comment on lines +28 to +33
for _, c := range cases {
t.Helper()
t.Run(c.name, func(t *testing.T) {
c.fn(t, ds)
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Misplaced t.Helper() call.

t.Helper() is called inside the loop but outside the subtest closure. It should either be removed (since this isn't a helper function) or moved inside the closure if intended to mark the subtest as a helper.

 	for _, c := range cases {
-		t.Helper()
 		t.Run(c.name, func(t *testing.T) {
 			c.fn(t, ds)
 		})
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for _, c := range cases {
t.Helper()
t.Run(c.name, func(t *testing.T) {
c.fn(t, ds)
})
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
c.fn(t, ds)
})
}
🤖 Prompt for AI Agents
In server/datastore/mysql/host_certificate_templates_test.go around lines 28 to
33, t.Helper() is called in the for-loop scope instead of inside the subtest
closure (or should be removed); either delete the t.Helper() call if this test
function is not a helper, or move the t.Helper() call inside the t.Run closure
as the first line (so the subtest is marked as a helper) to correct its
placement.

Comment on lines +516 to +518
func testUpdateHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
db := CreateMySQLDS(t)
nodeKey := uuid.New().String()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent datastore usage: creates new datastore instead of using passed parameter.

This test function creates a new datastore (db := CreateMySQLDS(t)) instead of using the ds parameter passed from the parent test. This is inconsistent with the other test functions and means ds goes unused.

 func testUpdateHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
-	db := CreateMySQLDS(t)
+	db := ds
 	nodeKey := uuid.New().String()

Alternatively, if a separate datastore is intentional for isolation, the function signature should be updated to not accept ds.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func testUpdateHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
db := CreateMySQLDS(t)
nodeKey := uuid.New().String()
func testUpdateHostCertificateTemplateStatus(t *testing.T, ds *Datastore) {
db := ds
nodeKey := uuid.New().String()
🤖 Prompt for AI Agents
In server/datastore/mysql/host_certificate_templates_test.go around lines
516-518, the test function declares a new datastore with db := CreateMySQLDS(t)
while it receives a ds parameter (making ds unused) — either remove the ds
parameter if an isolated datastore is intentional, or delete the CreateMySQLDS
call and use the passed-in ds for all datastore operations; update the function
signature and any callers accordingly to keep usage consistent with other tests.

Comment on lines +107 to +133
func (ds *Datastore) UpdateCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus) error {
// Validate the status.
if !status.IsValid() {
return ctxerr.Wrap(ctx, fmt.Errorf("Invalid status '%s'", string(status)))
}

// Attempt to update the certificate status for the given host and template.
result, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_certificate_templates
SET status = ?
WHERE host_uuid = ? AND certificate_template_id = ?
`, status, hostUUID, certificateTemplateID)
if err != nil {
return err
}

rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}

if rowsAffected == 0 {
return ctxerr.Wrap(ctx, notFound("Label").WithMessage(fmt.Sprintf("No certificate found for host UUID '%s' and template ID '%d'", hostUUID, certificateTemplateID)))
}

return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix resource name in not-found error and align error wrapping

Two small issues here:

  • The not-found case uses notFound("Label") even though this is updating host_certificate_templates, which will surface the wrong resource type to callers/metrics.
  • Errors from ExecContext/RowsAffected are returned bare, whereas the rest of this file wraps datastore errors with ctxerr.Wrap for context.

You can address both with a small tweak like:

 func (ds *Datastore) UpdateCertificateStatus(ctx context.Context, hostUUID string, certificateTemplateID uint, status fleet.MDMDeliveryStatus) error {
   // Validate the status.
   if !status.IsValid() {
-    return ctxerr.Wrap(ctx, fmt.Errorf("Invalid status '%s'", string(status)))
+    return ctxerr.Wrap(ctx, fmt.Errorf("invalid status '%s'", string(status)))
   }

   // Attempt to update the certificate status for the given host and template.
-  result, err := ds.writer(ctx).ExecContext(ctx, `
+  result, err := ds.writer(ctx).ExecContext(ctx, `
     UPDATE host_certificate_templates
     SET status = ?
     WHERE host_uuid = ? AND certificate_template_id = ?
-`, status, hostUUID, certificateTemplateID)
+`, status, hostUUID, certificateTemplateID)
   if err != nil {
-    return err
+    return ctxerr.Wrap(ctx, err, "update certificate status")
   }

   rowsAffected, err := result.RowsAffected()
   if err != nil {
-    return err
+    return ctxerr.Wrap(ctx, err, "rows affected updating certificate status")
   }

   if rowsAffected == 0 {
-    return ctxerr.Wrap(ctx, notFound("Label").WithMessage(fmt.Sprintf("No certificate found for host UUID '%s' and template ID '%d'", hostUUID, certificateTemplateID)))
+    return ctxerr.Wrap(ctx, notFound("CertificateTemplate").
+      WithMessage(fmt.Sprintf("No certificate found for host UUID '%s' and template ID '%d'", hostUUID, certificateTemplateID)))
   }

   return nil
 }

(Adjust the "CertificateTemplate" resource string to match whatever you use elsewhere for this table.)

🤖 Prompt for AI Agents
In server/datastore/mysql/host_certificate_templates.go around lines 107 to 133,
the not-found error uses notFound("Label") and raw errors from
ExecContext/RowsAffected are returned unwrapped; change the resource string to
notFound("CertificateTemplate") (or the canonical resource name used elsewhere
for this table) and wrap the ExecContext and RowsAffected error returns with
ctxerr.Wrap(ctx, err) so all datastore errors are consistently context-wrapped.

Comment on lines +623 to +630
if err := svc.AddFleetAgentToAndroidPolicy(ctx, r.Enterprise.Name(), hostConfigs); err != nil {
return ctxerr.Wrap(ctx, err, "add fleet agent to android policy with certificates")
}

// For new certificates
if err := r.DS.BulkInsertHostCertificateTemplates(ctx, newCertificates); err != nil {
return ctxerr.Wrap(ctx, err, "bulk insert host certificate templates")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential data inconsistency if bulk insert fails after policy update.

If AddFleetAgentToAndroidPolicy succeeds but BulkInsertHostCertificateTemplates fails, the Android devices will receive certificate template IDs but Fleet won't have the corresponding host_certificate_templates records. This could lead to devices attempting to use templates that Fleet doesn't recognize.

Consider wrapping these operations in a transaction or implementing compensating logic on failure.

🤖 Prompt for AI Agents
In server/mdm/android/service/profiles.go around lines 623 to 630, the code
updates the Android policy first then does a BulkInsertHostCertificateTemplates
which can fail and leave devices referencing templates that don't exist; modify
this flow so both operations are atomic: either (preferred) use a datastore
transaction that wraps AddFleetAgentToAndroidPolicy and
BulkInsertHostCertificateTemplates (begin transaction via r.DS, call both ops
using the transactional handle, commit on success, rollback on error), or if a
transaction is not available, implement compensating logic that on
BulkInsertHostCertificateTemplates failure calls a rollback API to remove the
fleet agent from the Android policy (e.g., RemoveFleetAgentFromAndroidPolicy)
and returns the original error; ensure errors from rollback are logged/combined
and that the function only returns after the system state is consistent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the BulkInsertHostCertificateTemplates fails, the android device will get the managedConfiguration with the certificate_template id. They will go and hit /api/fleetd/certificates/123, this will return the certificate template response without a status, and therefore without a SCEPChallenge or FleetChallenge. The next time the cron job runs, it will insert the host_certificate_template row. The android device hitting /api/fleetd/certificates/123 will get a pending status with a SCEPChallenge or FleetChallenge.

If we swap the order around and insert the host_certificate_template row first, and the AMAPI fails. The logic which only sends new certificate templates (templates with no host_certificate_template row) will ignore this template, and the android device won't get the AMAPI call. We cannot modify the logic that selects which templates to include a pending status, because it will continue sending AMAPI requests until the android device goes through the flow.

We COULD minimize this a bit by adding another status, call it "delivering" or "sending" that happens before "pending". And have the device update to "pending" when it reaches out to /api/fleetd/certificates/123. But even in that case there is a chance we will send AMAPI requests to the device multiple times.

@mostlikelee @dantecatalfamo , I think we will need to handle the failure case on the application, where we make a request to /api/fleetd/certificates/123 and if it doesn't contain a status/secrets we will have to ignore the request, and wait for another AMAPI request to come in to retry.

@getvictor getvictor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good overall. Had a couple comments.

return nil, ctxerr.Wrap(ctx, err, "getting certificate_template by id")
}

if template.Status != nil && *template.Status == fleet.MDMDeliveryPending {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

)

// ListAndroidHostUUIDsWithCertificateTemplates returns a batch of host UUIDs that have certificate templates to deliver
func (ds *Datastore) ListAndroidHostUUIDsWithCertificateTemplates(ctx context.Context, offset int, limit int) ([]string, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name WithCertificateTemplates doesn't seem right, since the query explicitly looking for ones without: host_certificate_templates.id IS NULL

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔
ListAndroidHostUUIDsWithPendingCertificateTemplates
or maybe..
ListAndroidHostUUIDsWithDeliverableCertificateTemplates

}

func testListAndroidHostUUIDsWithCertificateTemplates(t *testing.T, ds *Datastore) {
ctx := context.Background()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. We can use t.Context() now.

Comment thread server/mdm/android/service/profiles.go
}

// For new certificates
if err := r.DS.BulkInsertHostCertificateTemplates(ctx, newCertificates); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like we should update DB before we send managed configs updates to Android hosts. If we're updating 1K policies, this could take a while. So the first host could try to get its info, but it may not be in DB yet, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replied on the AI comment why I don't think that's a good idea.

host_certificate_templates.id IS NULL
ORDER BY hosts.uuid
LIMIT ? OFFSET ?
`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If gitops deletes a certificate template, we need to return the host uuid in the list of hosts with pending manageConfiguration changes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't delete the host_certificate_template row associated with the certificate_template, we could modify the query here to surface those hosts.

		SELECT uuid
		FROM (
			(
				SELECT DISTINCT
					hosts.uuid AS uuid
				FROM certificate_templates
				INNER JOIN hosts ON hosts.team_id = certificate_templates.team_id
				INNER JOIN host_mdm ON host_mdm.host_id = hosts.id
				LEFT JOIN host_certificate_templates
					ON host_certificate_templates.host_uuid = hosts.uuid
					AND host_certificate_templates.certificate_template_id = certificate_templates.id
				WHERE
					hosts.platform = 'android' AND
					host_mdm.enrolled = 1 AND
					host_certificate_templates.id IS NULL
			)
			UNION
			(
				SELECT DISTINCT
					host_certificate_templates.host_uuid AS uuid
				FROM host_certificate_templates
				LEFT JOIN certificate_templates
					ON certificate_templates.id = host_certificate_templates.certificate_template_id
				WHERE
					certificate_templates.id IS NULL
			)
		) AS combined
		ORDER BY uuid
		LIMIT ? OFFSET ?

however, this means that we won't be able to track deletions properly. Profiles have an MDMOperationType:

type MDMOperationType string

const (
	MDMOperationTypeInstall MDMOperationType = "install"
	MDMOperationTypeRemove  MDMOperationType = "remove"
)

We should most likely implement something similar with certificates.

@getvictor getvictor self-assigned this Dec 1, 2025

@getvictor getvictor left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes looks good since my last review. I see there are some merge conflicts that need resolving.

var stmt string
var args []interface{}

if teamID != nil && *teamID > 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Recommend for clarity:

Suggested change
GROUP BY 1`
GROUP BY hct.status`

FROM host_certificate_templates hct
INNER JOIN certificate_templates ct ON hct.certificate_template_id = ct.id
WHERE ct.team_id = ?
GROUP BY 1`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Recommend for clarity

Suggested change
GROUP BY 1`
GROUP BY hct.status`


require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, uint(1), result.Pending)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. Recommend adding a test case where there is more than 1 of a specific status.

Comment thread server/mdm/android/service/profiles.go Outdated
return ctxerr.Wrapf(ctx, err, "get android host %s", hostUUID)
}

enrollSecrets, err := r.DS.GetEnrollSecrets(ctx, androidHost.Host.TeamID)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we cache the enrollSecret so that we don't have to call it every time for 1000 hosts on the same team?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deploy certificates on Android hosts via GitOps - create the cron job

2 participants