diff --git a/api/portainer.go b/api/portainer.go index c130cbbd86..1200066369 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 ) diff --git a/app/react/components/Badge/Badge.tsx b/app/react/components/Badge/Badge.tsx index 3dfe05e02f..55461637b7 100644 --- a/app/react/components/Badge/Badge.tsx +++ b/app/react/components/Badge/Badge.tsx @@ -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['type'], 'custom'> diff --git a/app/react/sidebar/SidebarItem/SidebarItem.tsx b/app/react/sidebar/SidebarItem/SidebarItem.tsx index 7b875be9a4..16e661c829 100644 --- a/app/react/sidebar/SidebarItem/SidebarItem.tsx +++ b/app/react/sidebar/SidebarItem/SidebarItem.tsx @@ -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; } diff --git a/pkg/libpolicy/fingerprint.go b/pkg/libpolicy/fingerprint.go new file mode 100644 index 0000000000..3dba6f2dbb --- /dev/null +++ b/pkg/libpolicy/fingerprint.go @@ -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) +}