Compare commits

...

5 Commits

Author SHA1 Message Date
Malcolm Lockyer 5d24efd2e4 chore: bump version to 2.31.2 (#831) 2025-06-26 11:40:17 +12:00
Steven Kang 1831af9c48 fix: fetching values from both install and upgrade views - release 2.31 [R8S-368] (#821) 2025-06-24 15:46:13 +12:00
Malcolm Lockyer dca0e35e24 chore: bump version to 2.31.1 (#815) 2025-06-19 11:15:00 +12:00
James Player 4b5b682d0c feat(k8s): CRD in applications list and details 2.31.1 - [R8S-357] (#806)
Co-authored-by: stevensbkang <skan070@gmail.com>
2025-06-18 09:06:58 +12:00
Yajith Dayarathna 078dca33b8 fix(api-documentation): swagger document genration error (#794) 2025-06-12 13:39:15 +12:00
14 changed files with 140 additions and 66 deletions
@@ -611,7 +611,7 @@
"RequiredPasswordLength": 12 "RequiredPasswordLength": 12
}, },
"KubeconfigExpiry": "0", "KubeconfigExpiry": "0",
"KubectlShellImage": "portainer/kubectl-shell:2.31.0", "KubectlShellImage": "portainer/kubectl-shell:2.31.2",
"LDAPSettings": { "LDAPSettings": {
"AnonymousMode": true, "AnonymousMode": true,
"AutoCreateUsers": true, "AutoCreateUsers": true,
@@ -939,7 +939,7 @@
} }
], ],
"version": { "version": {
"VERSION": "{\"SchemaVersion\":\"2.31.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}" "VERSION": "{\"SchemaVersion\":\"2.31.2\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}, },
"webhooks": null "webhooks": null
} }
+1 -1
View File
@@ -81,7 +81,7 @@ type Handler struct {
} }
// @title PortainerCE API // @title PortainerCE API
// @version 2.31.0 // @version 2.31.2
// @description.markdown api-description.md // @description.markdown api-description.md
// @termsOfService // @termsOfService
+2 -2
View File
@@ -20,7 +20,7 @@ import (
// @param id path int true "Environment identifier" // @param id path int true "Environment identifier"
// @param namespace path string true "The namespace name the events are associated to" // @param namespace path string true "The namespace name the events are associated to"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success" // @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
@@ -68,7 +68,7 @@ func (handler *Handler) getKubernetesEventsForNamespace(w http.ResponseWriter, r
// @produce json // @produce json
// @param id path int true "Environment identifier" // @param id path int true "Environment identifier"
// @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa" // @param resourceId query string false "The resource id of the involved kubernetes object" example:"e5b021b6-4bce-4c06-bd3b-6cca906797aa"
// @success 200 {object} models.Event[] "Success" // @success 200 {object} []kubernetes.K8sEvent "Success"
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria." // @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions." // @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions." // @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
+21 -5
View File
@@ -38,14 +38,30 @@ type K8sApplication struct {
Labels map[string]string `json:"Labels,omitempty"` Labels map[string]string `json:"Labels,omitempty"`
Resource K8sApplicationResource `json:"Resource,omitempty"` Resource K8sApplicationResource `json:"Resource,omitempty"`
HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"` HorizontalPodAutoscaler *autoscalingv2.HorizontalPodAutoscaler `json:"HorizontalPodAutoscaler,omitempty"`
CustomResourceMetadata CustomResourceMetadata `json:"CustomResourceMetadata,omitempty"`
} }
type Metadata struct { type Metadata struct {
Labels map[string]string `json:"labels"` Labels map[string]string `json:"labels"`
} }
type CustomResourceMetadata struct {
Kind string `json:"kind"`
APIVersion string `json:"apiVersion"`
Plural string `json:"plural"`
}
type Pod struct { type Pod struct {
Status string `json:"Status"` Name string `json:"Name"`
ContainerName string `json:"ContainerName"`
Image string `json:"Image"`
ImagePullPolicy string `json:"ImagePullPolicy"`
Status string `json:"Status"`
NodeName string `json:"NodeName"`
PodIP string `json:"PodIP"`
UID string `json:"Uid"`
Resource K8sApplicationResource `json:"Resource,omitempty"`
CreationDate time.Time `json:"CreationDate"`
} }
type Configuration struct { type Configuration struct {
@@ -72,8 +88,8 @@ type TLSInfo struct {
// Existing types // Existing types
type K8sApplicationResource struct { type K8sApplicationResource struct {
CPURequest float64 `json:"CpuRequest"` CPURequest float64 `json:"CpuRequest,omitempty"`
CPULimit float64 `json:"CpuLimit"` CPULimit float64 `json:"CpuLimit,omitempty"`
MemoryRequest int64 `json:"MemoryRequest"` MemoryRequest int64 `json:"MemoryRequest,omitempty"`
MemoryLimit int64 `json:"MemoryLimit"` MemoryLimit int64 `json:"MemoryLimit,omitempty"`
} }
+1 -1
View File
@@ -1728,7 +1728,7 @@ type (
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "2.31.0" APIVersion = "2.31.2"
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support) // Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
APIVersionSupport = "STS" APIVersionSupport = "STS"
// Edition is what this edition of Portainer is called // Edition is what this edition of Portainer is called
@@ -1,7 +1,4 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Settings } from 'lucide-react';
import { Icon } from '@@/Icon';
import styles from './ViewLoading.module.css'; import styles from './ViewLoading.module.css';
@@ -18,12 +15,7 @@ export function ViewLoading({ message }: Props) {
<div className="sk-fold-cube" /> <div className="sk-fold-cube" />
<div className="sk-fold-cube" /> <div className="sk-fold-cube" />
</div> </div>
{message && ( {message && <span className={styles.message}>{message}</span>}
<span className={styles.message}>
{message}
<Icon icon={Settings} className="!ml-1 animate-spin-slow" />
</span>
)}
</div> </div>
); );
} }
@@ -44,7 +44,6 @@ export function UpgradeButton({
useCache useCache
); );
const versions = helmRepoVersionsQuery.data; const versions = helmRepoVersionsQuery.data;
const repo = versions?.[0]?.Repo;
// Combined loading state // Combined loading state
const isLoading = const isLoading =
@@ -63,17 +62,28 @@ export function UpgradeButton({
latestVersionQuery?.data && latestVersionQuery?.data &&
semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1 semverCompare(latestVersionAvailable, latestVersionQuery?.data) === 1
); );
const currentVersion = release?.chart.metadata?.version;
const currentRepo = versions?.find(
(v) =>
v.Chart === release?.chart.metadata?.name &&
v.AppVersion === release?.chart.metadata?.appVersion &&
v.Version === release?.chart.metadata?.version
)?.Repo;
const editableHelmRelease: UpdateHelmReleasePayload = { const editableHelmRelease: UpdateHelmReleasePayload = {
name: releaseName, name: releaseName,
namespace: namespace || '', namespace: namespace || '',
values: release?.values?.userSuppliedValues, values: release?.values?.userSuppliedValues,
chart: release?.chart.metadata?.name || '', chart: release?.chart.metadata?.name || '',
version: currentVersion, appVersion: release?.chart.metadata?.appVersion,
repo, version: release?.chart.metadata?.version,
repo: currentRepo ?? '',
}; };
const filteredVersions = currentRepo
? versions?.filter((v) => v.Repo === currentRepo) || []
: versions || [];
return ( return (
<div className="relative"> <div className="relative">
<LoadingButton <LoadingButton
@@ -151,7 +161,7 @@ export function UpgradeButton({
async function handleUpgrade() { async function handleUpgrade() {
const submittedUpgradeValues = await openUpgradeHelmModal( const submittedUpgradeValues = await openUpgradeHelmModal(
editableHelmRelease, editableHelmRelease,
versions filteredVersions
); );
if (submittedUpgradeValues) { if (submittedUpgradeValues) {
@@ -19,39 +19,48 @@ import { useHelmChartValues } from '../../queries/useHelmChartValues';
interface Props { interface Props {
onSubmit: OnSubmit<UpdateHelmReleasePayload>; onSubmit: OnSubmit<UpdateHelmReleasePayload>;
values: UpdateHelmReleasePayload; payload: UpdateHelmReleasePayload;
versions: ChartVersion[]; versions: ChartVersion[];
chartName: string; chartName: string;
repo: string;
} }
export function UpgradeHelmModal({ export function UpgradeHelmModal({
values, payload,
versions, versions,
onSubmit, onSubmit,
chartName, chartName,
repo,
}: Props) { }: Props) {
const versionOptions: Option<ChartVersion>[] = versions.map((version) => { const versionOptions: Option<ChartVersion>[] = versions.map((version) => {
const isCurrentVersion = version.Version === values.version; const repo = payload.repo === version.Repo ? version.Repo : '';
const label = `${version.Repo}@${version.Version}${ const isCurrentVersion =
version.AppVersion === payload.appVersion &&
version.Version === payload.version;
const label = `${repo}@${version.Version}${
isCurrentVersion ? ' (current)' : '' isCurrentVersion ? ' (current)' : ''
}`; }`;
return { return {
repo,
label, label,
value: version, value: version,
}; };
}); });
const defaultVersion = const defaultVersion =
versionOptions.find((v) => v.value.Version === values.version)?.value || versionOptions.find(
versionOptions[0]?.value; (v) =>
v.value.AppVersion === payload.appVersion &&
v.value.Version === payload.version &&
v.value.Repo === payload.repo
)?.value || versionOptions[0]?.value;
const [version, setVersion] = useState<ChartVersion>(defaultVersion); const [version, setVersion] = useState<ChartVersion>(defaultVersion);
const [userValues, setUserValues] = useState<string>(values.values || ''); const [userValues, setUserValues] = useState<string>(payload.values || '');
const [atomic, setAtomic] = useState<boolean>(true); const [atomic, setAtomic] = useState<boolean>(true);
const chartValuesRefQuery = useHelmChartValues({ const chartValuesRefQuery = useHelmChartValues({
chart: chartName, chart: chartName,
repo, repo: version.Repo,
version: version.Version, version: version.Version,
}); });
@@ -75,7 +84,7 @@ export function UpgradeHelmModal({
> >
<Input <Input
id="release-name-input" id="release-name-input"
value={values.name} value={payload.name}
readOnly readOnly
disabled disabled
data-cy="helm-release-name-input" data-cy="helm-release-name-input"
@@ -88,7 +97,7 @@ export function UpgradeHelmModal({
> >
<Input <Input
id="namespace-input" id="namespace-input"
value={values.namespace} value={payload.namespace}
readOnly readOnly
disabled disabled
data-cy="helm-namespace-input" data-cy="helm-namespace-input"
@@ -142,10 +151,10 @@ export function UpgradeHelmModal({
<Button <Button
onClick={() => onClick={() =>
onSubmit({ onSubmit({
name: values.name, name: payload.name,
values: userValues, values: userValues,
namespace: values.namespace, namespace: payload.namespace,
chart: values.chart, chart: payload.chart,
repo: version.Repo, repo: version.Repo,
version: version.Version, version: version.Version,
atomic, atomic,
@@ -165,13 +174,12 @@ export function UpgradeHelmModal({
} }
export async function openUpgradeHelmModal( export async function openUpgradeHelmModal(
values: UpdateHelmReleasePayload, payload: UpdateHelmReleasePayload,
versions: ChartVersion[] versions: ChartVersion[]
) { ) {
return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), { return openModal(withReactQuery(withCurrentUser(UpgradeHelmModal)), {
values, payload,
versions, versions,
chartName: values.chart, chartName: payload.chart,
repo: values.repo ?? '',
}); });
} }
@@ -10,12 +10,15 @@ interface HelmSearch {
} }
interface Entries { interface Entries {
[key: string]: { version: string }[]; [key: string]: { version: string; appVersion: string }[];
} }
export interface ChartVersion { export interface ChartVersion {
Chart?: string;
Repo: string; Repo: string;
Label?: string;
Version: string; Version: string;
AppVersion?: string;
} }
/** /**
@@ -77,8 +80,10 @@ async function getSearchHelmRepo(
const versions = data.entries[chart]; const versions = data.entries[chart];
return ( return (
versions?.map((v) => ({ versions?.map((v) => ({
Chart: chart,
Repo: repo, Repo: repo,
Version: v.version, Version: v.version,
AppVersion: v.appVersion,
})) ?? [] })) ?? []
); );
} catch (err) { } catch (err) {
+2 -1
View File
@@ -120,9 +120,10 @@ export interface InstallChartPayload {
export interface UpdateHelmReleasePayload { export interface UpdateHelmReleasePayload {
namespace: string; namespace: string;
values?: string; values?: string;
repo?: string; repo: string;
name: string; name: string;
chart: string; chart: string;
appVersion?: string;
version?: string; version?: string;
atomic?: boolean; atomic?: boolean;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
"author": "Portainer.io", "author": "Portainer.io",
"name": "portainer", "name": "portainer",
"homepage": "http://portainer.io", "homepage": "http://portainer.io",
"version": "2.31.0", "version": "2.31.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git@github.com:portainer/portainer.git" "url": "git@github.com:portainer/portainer.git"
+2
View File
@@ -36,6 +36,8 @@ type Release struct {
Manifest string `json:"manifest,omitempty"` Manifest string `json:"manifest,omitempty"`
// Hooks are all of the hooks declared for this release. // Hooks are all of the hooks declared for this release.
Hooks []*Hook `json:"hooks,omitempty"` Hooks []*Hook `json:"hooks,omitempty"`
// AppVersion is the app version of the release.
AppVersion string `json:"appVersion,omitempty"`
// Version is an int which represents the revision of the release. // Version is an int which represents the revision of the release.
Version int `json:"version,omitempty"` Version int `json:"version,omitempty"`
// Namespace is the kubernetes namespace of the release. // Namespace is the kubernetes namespace of the release.
+12 -2
View File
@@ -90,8 +90,17 @@ func (hspm *HelmSDKPackageManager) SearchRepo(searchRepoOpts options.SearchRepoO
return nil, errors.Wrap(err, "failed to ensure Helm directories exist") return nil, errors.Wrap(err, "failed to ensure Helm directories exist")
} }
repoName, err := getRepoNameFromURL(repoURL.String())
if err != nil {
log.Error().
Str("context", "HelmClient").
Err(err).
Msg("Failed to get hostname from URL")
return nil, err
}
// Download the index file and update repository configuration // Download the index file and update repository configuration
indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, searchRepoOpts.Repo) indexPath, err := downloadRepoIndex(repoURL.String(), repoSettings, repoName)
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
@@ -163,7 +172,8 @@ func downloadRepoIndex(repoURLString string, repoSettings *cli.EnvSettings, repo
// Create chart repository object // Create chart repository object
rep, err := repo.NewChartRepository( rep, err := repo.NewChartRepository(
&repo.Entry{ &repo.Entry{
URL: repoURLString, Name: repoName,
URL: repoURLString,
}, },
getter.All(repoSettings), getter.All(repoSettings),
) )
+47 -17
View File
@@ -2,7 +2,8 @@ package sdk
import ( import (
"fmt" "fmt"
"os" "net/url"
"strings"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libhelm/options" "github.com/portainer/portainer/pkg/libhelm/options"
@@ -32,24 +33,32 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("output_format", string(showOpts.OutputFormat)). Str("output_format", string(showOpts.OutputFormat)).
Msg("Showing chart information") Msg("Showing chart information")
// Initialize action configuration (no namespace or cluster access needed) repoURL, err := parseRepoURL(showOpts.Repo)
actionConfig := new(action.Configuration)
err := hspm.initActionConfig(actionConfig, "", nil)
if err != nil { if err != nil {
// error is already logged in initActionConfig log.Error().
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err) Str("context", "HelmClient").
Str("repo", showOpts.Repo).
Err(err).
Msg("Invalid repository URL")
return nil, err
} }
// Create temporary directory for chart download repoName, err := getRepoNameFromURL(repoURL.String())
tempDir, err := os.MkdirTemp("", "helm-show-*")
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Err(err). Err(err).
Msg("Failed to create temp directory") Msg("Failed to get hostname from URL")
return nil, fmt.Errorf("failed to create temp directory: %w", err) return nil, err
}
// Initialize action configuration (no namespace or cluster access needed)
actionConfig := new(action.Configuration)
err = hspm.initActionConfig(actionConfig, "", nil)
if err != nil {
// error is already logged in initActionConfig
return nil, fmt.Errorf("failed to initialize helm configuration: %w", err)
} }
defer os.RemoveAll(tempDir)
// Create showClient action // Create showClient action
showClient, err := initShowClient(actionConfig, showOpts) showClient, err := initShowClient(actionConfig, showOpts)
@@ -68,11 +77,12 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
Str("repo", showOpts.Repo). Str("repo", showOpts.Repo).
Msg("Locating chart") Msg("Locating chart")
chartPath, err := showClient.ChartPathOptions.LocateChart(showOpts.Chart, hspm.settings) fullChartPath := fmt.Sprintf("%s/%s", repoName, showOpts.Chart)
chartPath, err := showClient.ChartPathOptions.LocateChart(fullChartPath, hspm.settings)
if err != nil { if err != nil {
log.Error(). log.Error().
Str("context", "HelmClient"). Str("context", "HelmClient").
Str("chart", showOpts.Chart). Str("chart", fullChartPath).
Str("repo", showOpts.Repo). Str("repo", showOpts.Repo).
Err(err). Err(err).
Msg("Failed to locate chart") Msg("Failed to locate chart")
@@ -104,13 +114,10 @@ func (hspm *HelmSDKPackageManager) Show(showOpts options.ShowOptions) ([]byte, e
// and return the show client. // and return the show client.
func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) { func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOptions) (*action.Show, error) {
showClient := action.NewShowWithConfig(action.ShowAll, actionConfig) showClient := action.NewShowWithConfig(action.ShowAll, actionConfig)
showClient.ChartPathOptions.RepoURL = showOpts.Repo showClient.ChartPathOptions.Version = showOpts.Version
showClient.ChartPathOptions.Version = showOpts.Version // If version is "", it will use the latest version
// Set output type based on ShowOptions // Set output type based on ShowOptions
switch showOpts.OutputFormat { switch showOpts.OutputFormat {
case options.ShowAll:
showClient.OutputFormat = action.ShowAll
case options.ShowChart: case options.ShowChart:
showClient.OutputFormat = action.ShowChart showClient.OutputFormat = action.ShowChart
case options.ShowValues: case options.ShowValues:
@@ -127,3 +134,26 @@ func initShowClient(actionConfig *action.Configuration, showOpts options.ShowOpt
return showClient, nil return showClient, nil
} }
// getRepoNameFromURL extracts a unique repository identifier from a URL string.
// It combines hostname and path to ensure uniqueness across different repositories on the same host.
// Examples:
// - https://portainer.github.io/test-public-repo/ -> portainer.github.io-test-public-repo
// - https://portainer.github.io/another-repo/ -> portainer.github.io-another-repo
// - https://charts.helm.sh/stable -> charts.helm.sh-stable
func getRepoNameFromURL(urlStr string) (string, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("failed to parse URL: %w", err)
}
hostname := parsedURL.Hostname()
path := parsedURL.Path
path = strings.Trim(path, "/")
path = strings.ReplaceAll(path, "/", "-")
if path == "" {
return hostname, nil
}
return fmt.Sprintf("%s-%s", hostname, path), nil
}