diff --git a/app/portainer/environments/environment.service/create.ts b/app/portainer/environments/environment.service/create.ts new file mode 100644 index 000000000..ff6375ea1 --- /dev/null +++ b/app/portainer/environments/environment.service/create.ts @@ -0,0 +1,174 @@ +import PortainerError from '@/portainer/error'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { + Environment, + EnvironmentGroupId, + EnvironmentCreationTypes, + TagId, +} from '../types'; + +import { arrayToJson, buildUrl, json2formData } from './utils'; + +export async function createLocalEndpoint( + name = 'local', + url = '', + publicUrl = '', + groupId: EnvironmentGroupId = 1, + tagIds: TagId[] = [] +) { + let endpointUrl = url; + if (endpointUrl !== '') { + if (endpointUrl.includes('//./pipe/')) { + endpointUrl = `unix://${url}`; + } else { + // Windows named pipe + endpointUrl = `npipe://${url}`; + } + } + + try { + return await createEndpoint( + name, + EnvironmentCreationTypes.LocalDockerEnvironment, + { url: endpointUrl, publicUrl, groupId, tagIds } + ); + } catch (err) { + throw new PortainerError('Unable to create environment', err as Error); + } +} + +export async function createLocalKubernetesEndpoint( + name = 'local', + tagIds: TagId[] = [] +) { + try { + return await createEndpoint( + name, + EnvironmentCreationTypes.LocalKubernetesEnvironment, + { tagIds, groupId: 1, tls: { skipClientVerify: true, skipVerify: true } } + ); + } catch (err) { + throw new PortainerError('Unable to create environment', err as Error); + } +} + +export async function createAzureEndpoint( + name: string, + applicationId: string, + tenantId: string, + authenticationKey: string, + groupId: EnvironmentGroupId, + tagIds: TagId[] +) { + try { + await createEndpoint(name, EnvironmentCreationTypes.AzureEnvironment, { + groupId, + tagIds, + azure: { applicationId, tenantId, authenticationKey }, + }); + } catch (err) { + throw new PortainerError('Unable to connect to Azure', err as Error); + } +} + +interface TLSSettings { + skipVerify?: boolean; + skipClientVerify?: boolean; + caCertFile?: File; + certFile?: File; + keyFile?: File; +} + +interface AzureSettings { + applicationId: string; + tenantId: string; + authenticationKey: string; +} + +interface EndpointOptions { + url?: string; + publicUrl?: string; + groupId?: EnvironmentGroupId; + tagIds?: TagId[]; + checkinInterval?: number; + azure?: AzureSettings; + tls?: TLSSettings; +} + +export async function createRemoteEndpoint( + name: string, + creationType: EnvironmentCreationTypes, + options?: EndpointOptions +) { + let endpointUrl = options?.url; + if (creationType !== EnvironmentCreationTypes.EdgeAgentEnvironment) { + endpointUrl = `tcp://${endpointUrl}`; + } + + try { + return await createEndpoint(name, creationType, { + ...options, + url: endpointUrl, + }); + } catch (err) { + throw new PortainerError('Unable to create environment', err as Error); + } +} + +async function createEndpoint( + name: string, + creationType: EnvironmentCreationTypes, + options?: EndpointOptions +) { + let payload: Record<string, unknown> = { + Name: name, + EndpointCreationType: creationType, + }; + + if (options) { + payload = { + ...payload, + URL: options.url, + PublicURL: options.publicUrl, + GroupID: options.groupId, + TagIds: arrayToJson(options.tagIds), + CheckinInterval: options.checkinInterval, + }; + + const { tls, azure } = options; + + if (tls) { + payload = { + ...payload, + TLS: true, + TLSSkipVerify: tls.skipVerify, + TLSSkipClientVerify: tls.skipClientVerify, + TLSCACertFile: tls.caCertFile, + TLSCertFile: tls.certFile, + TLSKeyFile: tls.keyFile, + }; + } + + if (azure) { + payload = { + ...payload, + AzureApplicationID: azure.applicationId, + AzureTenantID: azure.tenantId, + AzureAuthenticationKey: azure.authenticationKey, + }; + } + } + + const formPayload = json2formData(payload); + try { + const { data: endpoint } = await axios.post<Environment>( + buildUrl(), + formPayload + ); + + return endpoint; + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/portainer/environments/environment.service/index.ts b/app/portainer/environments/environment.service/index.ts new file mode 100644 index 000000000..563130d28 --- /dev/null +++ b/app/portainer/environments/environment.service/index.ts @@ -0,0 +1,224 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { + Environment, + EnvironmentGroupId, + EnvironmentId, + EnvironmentType, + EnvironmentSettings, + TagId, + TeamId, + UserId, +} from '../types'; + +import { arrayToJson, buildUrl } from './utils'; + +interface EndpointsQuery { + search?: string; + types?: EnvironmentType[]; + tagIds?: TagId[]; + endpointIds?: EnvironmentId[]; + tagsPartialMatch?: boolean; + groupId?: EnvironmentGroupId; +} + +export async function getEndpoints( + start: number, + limit: number, + { types, tagIds, endpointIds, ...query }: EndpointsQuery = {} +) { + if (tagIds && tagIds.length === 0) { + return { totalCount: 0, value: <Environment[]>[] }; + } + + const url = buildUrl(); + + const params: Record<string, unknown> = { start, limit, ...query }; + + if (types) { + params.types = arrayToJson(types); + } + + if (tagIds) { + params.tagIds = arrayToJson(tagIds); + } + + if (endpointIds) { + params.endpointIds = arrayToJson(endpointIds); + } + + try { + const response = await axios.get<Environment[]>(url, { params }); + + const totalCount = response.headers['X-Total-Count']; + + return { totalCount: parseInt(totalCount, 10), value: response.data }; + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function getEndpoint(id: EnvironmentId) { + try { + const { data: endpoint } = await axios.get<Environment>(buildUrl(id)); + return endpoint; + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function snapshotEndpoints() { + try { + await axios.post<void>(buildUrl(undefined, 'snapshot')); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function snapshotEndpoint(id: EnvironmentId) { + try { + await axios.post<void>(buildUrl(id, 'snapshot')); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function endpointsByGroup( + start: number, + limit: number, + search: string, + groupId: EnvironmentGroupId +) { + return getEndpoints(start, limit, { search, groupId }); +} + +export async function disassociateEndpoint(id: EnvironmentId) { + try { + await axios.delete(buildUrl(id, 'association')); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +interface UpdatePayload { + TLSCACert?: File; + TLSCert?: File; + TLSKey?: File; + + Name: string; + PublicURL: string; + GroupID: EnvironmentGroupId; + TagIds: TagId[]; + + EdgeCheckinInterval: number; + + TLS: boolean; + TLSSkipVerify: boolean; + TLSSkipClientVerify: boolean; + AzureApplicationID: string; + AzureTenantID: string; + AzureAuthenticationKey: string; +} + +async function uploadTLSFilesForEndpoint( + id: EnvironmentId, + tlscaCert?: File, + tlsCert?: File, + tlsKey?: File +) { + await Promise.all([ + uploadCert('ca', tlscaCert), + uploadCert('cert', tlsCert), + uploadCert('key', tlsKey), + ]); + + function uploadCert(type: 'ca' | 'cert' | 'key', cert?: File) { + if (!cert) { + return null; + } + try { + return axios.post<void>(`upload/tls/${type}`, cert, { + params: { folder: id }, + }); + } catch (e) { + throw parseAxiosError(e as Error); + } + } +} + +export async function updateEndpoint( + id: EnvironmentId, + payload: UpdatePayload +) { + try { + await uploadTLSFilesForEndpoint( + id, + payload.TLSCACert, + payload.TLSCert, + payload.TLSKey + ); + + const { data: endpoint } = await axios.put<Environment>( + buildUrl(id), + payload + ); + + return endpoint; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to update environment'); + } +} + +export async function deleteEndpoint(id: EnvironmentId) { + try { + await axios.delete(buildUrl(id)); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function updatePoolAccess( + id: EnvironmentId, + resourcePool: string, + usersToAdd: UserId[], + teamsToAdd: TeamId[], + usersToRemove: UserId[], + teamsToRemove: TeamId[] +) { + try { + await axios.put<void>(`${buildUrl(id, 'pools')}/${resourcePool}/access`, { + usersToAdd, + teamsToAdd, + usersToRemove, + teamsToRemove, + }); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function forceUpdateService( + id: EnvironmentId, + serviceID: string, + pullImage: boolean +) { + try { + await axios.put(buildUrl(id, 'forceupdateservice'), { + serviceID, + pullImage, + }); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function updateSettings( + id: EnvironmentId, + settings: EnvironmentSettings +) { + try { + await axios.put(buildUrl(id, 'settings'), settings); + } catch (e) { + throw parseAxiosError(e as Error); + } +} diff --git a/app/portainer/environments/environment.service/registries.ts b/app/portainer/environments/environment.service/registries.ts new file mode 100644 index 000000000..4062d79cd --- /dev/null +++ b/app/portainer/environments/environment.service/registries.ts @@ -0,0 +1,70 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { + EnvironmentId, + TeamAccessPolicies, + UserAccessPolicies, +} from '../types'; + +import { buildUrl } from './utils'; + +export type RegistryId = number; +export interface Registry { + Id: RegistryId; +} + +interface RegistryAccess { + UserAccessPolicies: UserAccessPolicies; + TeamAccessPolicies: TeamAccessPolicies; + Namespaces: string[]; +} + +export async function updateEnvironmentRegistryAccess( + id: EnvironmentId, + registryId: RegistryId, + access: RegistryAccess +) { + try { + await axios.put<void>(buildRegistryUrl(id, registryId), access); + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function getEnvironmentRegistries( + id: EnvironmentId, + namespace: string +) { + try { + const { data } = await axios.get<Registry[]>(buildRegistryUrl(id), { + params: { namespace }, + }); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +export async function getEnvironmentRegistry( + endpointId: EnvironmentId, + registryId: RegistryId +) { + try { + const { data } = await axios.get<Registry>( + buildRegistryUrl(endpointId, registryId) + ); + return data; + } catch (e) { + throw parseAxiosError(e as Error); + } +} + +function buildRegistryUrl(id: EnvironmentId, registryId?: RegistryId) { + let url = `${buildUrl(id)}/registries`; + + if (registryId) { + url += `/${registryId}`; + } + + return url; +} diff --git a/app/portainer/environments/environment.service/utils.ts b/app/portainer/environments/environment.service/utils.ts new file mode 100644 index 000000000..1ec5c03a3 --- /dev/null +++ b/app/portainer/environments/environment.service/utils.ts @@ -0,0 +1,36 @@ +import { EnvironmentId } from '../types'; + +export function buildUrl(id?: EnvironmentId, action?: string) { + let baseUrl = 'endpoints'; + if (id) { + baseUrl += `/${id}`; + } + + if (action) { + baseUrl += `/${action}`; + } + + return baseUrl; +} + +export function arrayToJson<T>(arr?: Array<T>) { + if (!arr) { + return ''; + } + + return JSON.stringify(arr); +} + +export function json2formData(json: Record<string, unknown>) { + const formData = new FormData(); + + Object.entries(json).forEach(([key, value]) => { + if (typeof value === 'undefined' || value === null) { + return; + } + + formData.append(key, value as string); + }); + + return formData; +} diff --git a/app/portainer/environments/types.ts b/app/portainer/environments/types.ts index 4be0fa67e..3ab553913 100644 --- a/app/portainer/environments/types.ts +++ b/app/portainer/environments/types.ts @@ -1,12 +1,118 @@ export type EnvironmentId = number; +export enum EnvironmentType { + // Docker represents an environment(endpoint) connected to a Docker environment(endpoint) + Docker = 1, + // AgentOnDocker represents an environment(endpoint) connected to a Portainer agent deployed on a Docker environment(endpoint) + AgentOnDocker, + // Azure represents an environment(endpoint) connected to an Azure environment(endpoint) + Azure, + // EdgeAgentOnDocker represents an environment(endpoint) connected to an Edge agent deployed on a Docker environment(endpoint) + EdgeAgentOnDocker, + // KubernetesLocal represents an environment(endpoint) connected to a local Kubernetes environment(endpoint) + KubernetesLocal, + // AgentOnKubernetes represents an environment(endpoint) connected to a Portainer agent deployed on a Kubernetes environment(endpoint) + AgentOnKubernetes, + // EdgeAgentOnKubernetes represents an environment(endpoint) connected to an Edge agent deployed on a Kubernetes environment(endpoint) + EdgeAgentOnKubernetes, +} + +export type TagId = number; + +export interface Tag { + Id: TagId; + Name: string; +} + export enum EnvironmentStatus { Up = 1, - Down = 2, + Down, +} + +export interface DockerSnapshot { + TotalCPU: number; + TotalMemory: number; + NodeCount: number; + ImageCount: number; + VolumeCount: number; + RunningContainerCount: number; + StoppedContainerCount: number; + HealthyContainerCount: number; + UnhealthyContainerCount: number; + Time: number; + StackCount: number; + ServiceCount: number; + Swarm: boolean; + DockerVersion: string; +} + +export interface KubernetesSnapshot { + KubernetesVersion: string; + TotalCPU: number; + TotalMemory: number; + Time: number; + NodeCount: number; +} + +export interface KubernetesSettings { + Snapshots: KubernetesSnapshot[]; } export interface Environment { Id: EnvironmentId; + Type: EnvironmentType; + TagIds: TagId[]; + GroupName: string; + EdgeID?: string; + EdgeCheckinInterval?: number; + LastCheckInDate?: number; + Name: string; Status: EnvironmentStatus; - PublicURL: string; + URL: string; + Snapshots: DockerSnapshot[]; + Kubernetes: KubernetesSettings; + PublicURL?: string; } + +/** + * TS reference of endpoint_create.go#EndpointCreationType iota + */ +export enum EnvironmentCreationTypes { + LocalDockerEnvironment = 1, + AgentEnvironment, + AzureEnvironment, + EdgeAgentEnvironment, + LocalKubernetesEnvironment, +} + +export type EnvironmentGroupId = number; + +export interface EnvironmentSettings { + // Whether non-administrator should be able to use bind mounts when creating containers + allowBindMountsForRegularUsers: boolean; + // Whether non-administrator should be able to use privileged mode when creating containers + allowPrivilegedModeForRegularUsers: boolean; + // Whether non-administrator should be able to browse volumes + allowVolumeBrowserForRegularUsers: boolean; + // Whether non-administrator should be able to use the host pid + allowHostNamespaceForRegularUsers: boolean; + // Whether non-administrator should be able to use device mapping + allowDeviceMappingForRegularUsers: boolean; + // Whether non-administrator should be able to manage stacks + allowStackManagementForRegularUsers: boolean; + // Whether non-administrator should be able to use container capabilities + allowContainerCapabilitiesForRegularUsers: boolean; + // Whether non-administrator should be able to use sysctl settings + allowSysctlSettingForRegularUsers: boolean; + // Whether host management features are enabled + enableHostManagementFeatures: boolean; +} + +export type UserId = number; +export type TeamId = number; +export type RoleId = number; +interface AccessPolicy { + RoleId: RoleId; +} +export type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy +export type TeamAccessPolicies = Record<TeamId, AccessPolicy>;