chore(policies): use generic policy reconcile system so more than helm can be used [c9s-88] (#2613)

This commit is contained in:
Ali
2026-06-05 07:47:25 +12:00
committed by GitHub
parent d2b56efcb4
commit 0143393a8c
4 changed files with 171 additions and 81 deletions
+50 -8
View File
@@ -560,8 +560,9 @@ type (
}
PolicyChartSummary struct {
ChartName string `json:"ChartName"`
Fingerprint string `json:"Fingerprint"`
ChartName string `json:"ChartName"`
Fingerprint string `json:"Fingerprint"`
PolicyID PolicyID `json:"PolicyID,omitempty"` // 0 when server hasn't populated the field
}
PolicyChartStatus struct {
@@ -583,17 +584,19 @@ type (
}
PolicyChartBundle struct {
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
ReleaseName string `json:"ReleaseName,omitempty"`
PolicyChartSummary `mapstructure:",squash"`
EncodedTgz string `json:"EncodedTgz"`
Namespace string `json:"Namespace"`
ReleaseName string `json:"ReleaseName,omitempty"`
// Base64 YAML kubectl-applied by the agent before Helm install when set (e.g. Gatekeeper gatekeeper-system namespace + PSA labels).
PreReleaseManifest string `json:"PreReleaseManifest,omitempty"`
EncodedValues string `json:"EncodedValues"`
PreInstallDeletions []ResourceDeletion `json:"PreInstallDeletions,omitempty"`
PreInstallAdoptions []ResourceAdoption `json:"PreInstallAdoptions,omitempty"`
// WaitForCRDs lists CRD names that must be registered in API discovery after
// this chart installs before the agent proceeds to the next chart.
WaitForCRDs []string `json:"WaitForCRDs,omitempty"`
// NoWait disables waiting for pods to be ready after install.
// Set to true for externally sourced charts whose startup timing cannot be controlled,
// to avoid leaving the release stuck in pending-install.
NoWait bool `json:"NoWait,omitempty"`
}
@@ -623,6 +626,45 @@ type (
PolicyID int
// PolicyDesiredState is the per-policy desired state sent from server to agent
// in PollStatusResponse.PolicyStates (per-policy payload format).
PolicyDesiredState struct {
PolicyID PolicyID `json:"policyID"`
Type string `json:"type"` // e.g. "helm-k8s"
Fingerprint string `json:"fingerprint"` // install-affecting only; restore manifest excluded
Config []byte `json:"config"` // handler-specific config blob (e.g. HelmPolicyConfig JSON)
}
// PolicyStatesAsyncPayload is the value of an async "policyStates" command.
// States carries per-policy desired state; ChartBundles carries helm chart tarballs
// for policies that need installing (emitted only on mutation, never idle polls).
PolicyStatesAsyncPayload struct {
States []PolicyDesiredState `json:"states"`
ChartBundles []PolicyChartBundle `json:"chartBundles,omitempty"`
RestoreBundle RestoreSettingsBundle `json:"restoreBundle,omitempty"`
}
// PolicyActualState is the per-policy actual state reported by the agent
// via PUT /endpoints/{id}/edge/policies/statuses.
PolicyActualState struct {
PolicyID PolicyID `json:"policyID"`
Type string `json:"type"`
Fingerprint string `json:"fingerprint"`
Status string `json:"status"` // applying|applied|failed|removing
Message string `json:"message,omitempty"`
}
// HelmPolicyConfig is the Config payload for "helm-k8s" PolicyDesiredState entries.
// Bundles are not included in the poll response Config — they travel separately
// (sync: on-demand GetCharts; async: PolicyStatesCommandPayload.ChartBundles).
// RestoreSettings is helm-internal metadata and is intentionally NOT part of the
// fingerprint — see Fingerprint contract in reconcile-refactor-plan.md.
HelmPolicyConfig struct {
Charts []PolicyChartSummary `json:"charts"`
Bundles []PolicyChartBundle `json:"bundles,omitempty"`
RestoreSettings *RestoreSettings `json:"restoreSettings,omitempty"`
}
// PolicyType represents the type of policy
PolicyType string
)
+75 -73
View File
@@ -5,81 +5,83 @@ import { AutomationTestingProps } from '@/types';
import { Icon, IconProps } from '@@/Icon';
// the classes are typed in full because tailwind doesn't render the interpolated classes
const badge = cva('inline-flex w-fit items-center gap-1 font-medium', {
variants: {
type: {
success:
'bg-success-2 text-success-9 th-dark:bg-success-10 th-dark:text-success-3 th-highcontrast:bg-success-10 th-highcontrast:text-success-3',
warn: 'bg-warning-2 text-warning-9 th-dark:bg-warning-10 th-dark:text-warning-3 th-highcontrast:bg-warning-10 th-highcontrast:text-warning-3',
danger:
'bg-error-2 text-error-9 th-dark:bg-error-10 th-dark:text-error-3 th-highcontrast:bg-error-10 th-highcontrast:text-error-3',
info: 'bg-blue-2 text-blue-9 th-dark:bg-blue-10 th-dark:text-blue-3 th-highcontrast:bg-blue-10 th-highcontrast:text-blue-3',
// the secondary classes are a bit darker in light mode and a bit lighter in dark mode
successSecondary:
'bg-success-3 text-success-9 th-dark:bg-success-9 th-dark:text-success-3 th-highcontrast:bg-success-9 th-highcontrast:text-success-3',
warnSecondary:
'bg-warning-3 text-warning-9 th-dark:bg-warning-9 th-dark:text-warning-3 th-highcontrast:bg-warning-9 th-highcontrast:text-warning-3',
dangerSecondary:
'bg-error-3 text-error-9 th-dark:bg-error-9 th-dark:text-error-3 th-highcontrast:bg-error-9 th-highcontrast:text-error-3',
infoSecondary:
'bg-blue-3 text-blue-9 th-dark:bg-blue-9 th-dark:text-blue-3 th-highcontrast:bg-blue-9 th-highcontrast:text-blue-3',
muted:
'bg-gray-3 text-gray-9 th-dark:bg-gray-9 th-dark:text-gray-3 th-highcontrast:bg-gray-9 th-highcontrast:text-gray-3',
accent:
'bg-indigo-2 text-indigo-9 th-dark:bg-indigo-10 th-dark:text-indigo-3 th-highcontrast:bg-indigo-10 th-highcontrast:text-indigo-3',
custom: '',
const badge = cva(
'inline-flex w-fit items-center gap-1 font-medium [&>a]:hover:text-inherit [&>a]:text-inherit',
{
variants: {
type: {
success:
'bg-success-2 text-success-9 th-dark:bg-success-10 th-dark:text-success-3 th-highcontrast:bg-success-10 th-highcontrast:text-success-3',
warn: 'bg-warning-2 text-warning-9 th-dark:bg-warning-10 th-dark:text-warning-3 th-highcontrast:bg-warning-10 th-highcontrast:text-warning-3',
danger:
'bg-error-2 text-error-9 th-dark:bg-error-10 th-dark:text-error-3 th-highcontrast:bg-error-10 th-highcontrast:text-error-3',
info: 'bg-blue-2 text-blue-9 th-dark:bg-blue-10 th-dark:text-blue-3 th-highcontrast:bg-blue-10 th-highcontrast:text-blue-3',
// the secondary classes are a bit darker in light mode and a bit lighter in dark mode
successSecondary:
'bg-success-3 text-success-9 th-dark:bg-success-9 th-dark:text-success-3 th-highcontrast:bg-success-9 th-highcontrast:text-success-3',
warnSecondary:
'bg-warning-3 text-warning-9 th-dark:bg-warning-9 th-dark:text-warning-3 th-highcontrast:bg-warning-9 th-highcontrast:text-warning-3',
dangerSecondary:
'bg-error-3 text-error-9 th-dark:bg-error-9 th-dark:text-error-3 th-highcontrast:bg-error-9 th-highcontrast:text-error-3',
infoSecondary:
'bg-blue-3 text-blue-9 th-dark:bg-blue-9 th-dark:text-blue-3 th-highcontrast:bg-blue-9 th-highcontrast:text-blue-3',
muted:
'bg-gray-3 text-gray-9 th-dark:bg-gray-9 th-dark:text-gray-3 th-highcontrast:bg-gray-9 th-highcontrast:text-gray-3',
accent:
'bg-indigo-2 text-indigo-9 th-dark:bg-indigo-10 th-dark:text-indigo-3 th-highcontrast:bg-indigo-10 th-highcontrast:text-indigo-3',
custom: '',
},
shape: {
pill: 'rounded-full',
rect: 'rounded',
},
size: {
sm: 'px-1.5 py-px text-[10px]',
md: '!text-xs px-2 py-0.5',
},
bordered: {
true: '!border border-solid th-highcontrast:border-white',
},
},
shape: {
pill: 'rounded-full',
rect: 'rounded',
compoundVariants: [
{
type: ['success', 'successSecondary'],
bordered: true,
className: 'border-success-4 th-dark:border-success-8',
},
{
type: ['warn', 'warnSecondary'],
bordered: true,
className: 'border-warning-4 th-dark:border-warning-8',
},
{
type: ['danger', 'dangerSecondary'],
bordered: true,
className: 'border-error-4 th-dark:border-error-8',
},
{
type: ['info', 'infoSecondary'],
bordered: true,
className: 'border-blue-4 th-dark:border-blue-8',
},
{
type: 'muted',
bordered: true,
className: 'border-gray-4 th-dark:border-gray-8',
},
{
type: 'accent',
bordered: true,
className: 'border-indigo-4 th-dark:border-indigo-8',
},
],
defaultVariants: {
type: 'info',
shape: 'pill',
size: 'md',
},
size: {
sm: 'px-1.5 py-px text-[10px]',
md: '!text-xs px-2 py-0.5',
},
bordered: {
true: '!border border-solid th-highcontrast:border-white',
},
},
compoundVariants: [
{
type: ['success', 'successSecondary'],
bordered: true,
className: 'border-success-4 th-dark:border-success-8',
},
{
type: ['warn', 'warnSecondary'],
bordered: true,
className: 'border-warning-4 th-dark:border-warning-8',
},
{
type: ['danger', 'dangerSecondary'],
bordered: true,
className: 'border-error-4 th-dark:border-error-8',
},
{
type: ['info', 'infoSecondary'],
bordered: true,
className: 'border-blue-4 th-dark:border-blue-8',
},
{
type: 'muted',
bordered: true,
className: 'border-gray-4 th-dark:border-gray-8',
},
{
type: 'accent',
bordered: true,
className: 'border-indigo-4 th-dark:border-indigo-8',
},
],
defaultVariants: {
type: 'info',
shape: 'pill',
size: 'md',
},
});
}
);
export type BadgeType = NonNullable<
Exclude<VariantProps<typeof badge>['type'], 'custom'>
@@ -20,6 +20,9 @@ interface Props extends AutomationTestingProps {
label: string;
isSubMenu?: boolean;
ignorePaths?: string[];
/** When a create or detail path id differs from the list path, includePaths can be used to specify which paths should also mark the item as active.
*
* E.g. including the portainer.wizard.endpoints (create environment) path to activate the portainer.endpoints sidebar item. */
includePaths?: string[];
count?: number;
}
+43
View File
@@ -0,0 +1,43 @@
package libpolicy
import (
"hash/fnv"
"sort"
"strconv"
"github.com/segmentio/encoding/json"
portainer "github.com/portainer/portainer/api"
)
// ConfigFingerprint is the generic primitive: FNV-1a over serialized policy
// config bytes. It is a change detector, not a security boundary; collisions
// are acceptable because a later poll or policy edit will retry reconciliation.
// Callers must ensure the bytes are deterministic (e.g. collections sorted
// before marshaling).
//
// New policy types should call this after marshaling their own config struct.
func ConfigFingerprint(config []byte) string {
h := fnv.New32a()
h.Write(config)
return strconv.FormatUint(uint64(h.Sum32()), 16)
}
// HelmPolicyFingerprint computes a fingerprint for a Helm policy from its chart
// summaries. Charts are sorted by name before hashing so the result is
// order-independent. The restore manifest is excluded because it does not
// affect what gets installed.
//
// Both server-ee and the agent import this function to guarantee they compute
// identical fingerprints for the same chart summaries.
func HelmPolicyFingerprint(chartSummaries []portainer.PolicyChartSummary) string {
sorted := make([]portainer.PolicyChartSummary, len(chartSummaries))
copy(sorted, chartSummaries)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].ChartName < sorted[j].ChartName })
b, err := json.Marshal(portainer.HelmPolicyConfig{Charts: sorted})
if err != nil {
// json.Marshal of a known struct with no custom marshalers cannot error.
return ""
}
return ConfigFingerprint(b)
}