mirror of https://github.com/portainer/portainer
feat(k8s): add the ability to deploy from a manifest URL (#5550)
parent
1220ae7571
commit
70602cf7c8
|
@ -16,6 +16,7 @@ import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
k "github.com/portainer/portainer/api/kubernetes"
|
k "github.com/portainer/portainer/api/kubernetes"
|
||||||
|
"github.com/portainer/portainer/api/http/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultReferenceName = "refs/heads/master"
|
const defaultReferenceName = "refs/heads/master"
|
||||||
|
@ -37,6 +38,12 @@ type kubernetesGitDeploymentPayload struct {
|
||||||
FilePathInRepository string
|
FilePathInRepository string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type kubernetesManifestURLDeploymentPayload struct {
|
||||||
|
Namespace string
|
||||||
|
ComposeFormat bool
|
||||||
|
ManifestURL string
|
||||||
|
}
|
||||||
|
|
||||||
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
|
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
|
||||||
if govalidator.IsNull(payload.StackFileContent) {
|
if govalidator.IsNull(payload.StackFileContent) {
|
||||||
return errors.New("Invalid stack file content")
|
return errors.New("Invalid stack file content")
|
||||||
|
@ -66,6 +73,13 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
|
||||||
|
if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
|
||||||
|
return errors.New("Invalid manifest URL")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type createKubernetesStackResponse struct {
|
type createKubernetesStackResponse struct {
|
||||||
Output string `json:"Output"`
|
Output string `json:"Output"`
|
||||||
}
|
}
|
||||||
|
@ -171,6 +185,61 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
|
||||||
return response.JSON(w, resp)
|
return response.JSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||||
|
var payload kubernetesManifestURLDeploymentPayload
|
||||||
|
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackID := handler.DataStore.Stack().GetNextIdentifier()
|
||||||
|
stack := &portainer.Stack{
|
||||||
|
ID: portainer.StackID(stackID),
|
||||||
|
Type: portainer.KubernetesStack,
|
||||||
|
EndpointID: endpoint.ID,
|
||||||
|
EntryPoint: filesystem.ManifestFileDefaultName,
|
||||||
|
Status: portainer.StackStatusActive,
|
||||||
|
CreationDate: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestContent []byte
|
||||||
|
manifestContent, err := client.Get(payload.ManifestURL, 30)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve manifest from URL", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
|
||||||
|
}
|
||||||
|
stack.ProjectPath = projectPath
|
||||||
|
|
||||||
|
doCleanUp := true
|
||||||
|
defer handler.cleanUp(stack, &doCleanUp)
|
||||||
|
|
||||||
|
output, err := handler.deployKubernetesStack(r, endpoint, string(manifestContent), payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{
|
||||||
|
StackID: stackID,
|
||||||
|
Name: stack.Name,
|
||||||
|
Owner: stack.CreatedBy,
|
||||||
|
Kind: "url",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.DataStore.Stack().CreateStack(stack)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &createKubernetesStackResponse{
|
||||||
|
Output: output,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, resp)
|
||||||
|
}
|
||||||
|
|
||||||
func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) {
|
func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) {
|
||||||
handler.stackCreationMutex.Lock()
|
handler.stackCreationMutex.Lock()
|
||||||
defer handler.stackCreationMutex.Unlock()
|
defer handler.stackCreationMutex.Unlock()
|
||||||
|
|
|
@ -149,6 +149,8 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req
|
||||||
return handler.createKubernetesStackFromFileContent(w, r, endpoint)
|
return handler.createKubernetesStackFromFileContent(w, r, endpoint)
|
||||||
case "repository":
|
case "repository":
|
||||||
return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
|
return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
|
||||||
|
case "url":
|
||||||
|
return handler.createKubernetesStackFromManifestURL(w, r, endpoint)
|
||||||
}
|
}
|
||||||
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
|
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,11 @@ export const KubernetesDeployBuildMethods = Object.freeze({
|
||||||
GIT: 1,
|
GIT: 1,
|
||||||
WEB_EDITOR: 2,
|
WEB_EDITOR: 2,
|
||||||
CUSTOM_TEMPLATE: 3,
|
CUSTOM_TEMPLATE: 3,
|
||||||
|
URL: 4
|
||||||
});
|
});
|
||||||
|
|
||||||
export const KubernetesDeployRequestMethods = Object.freeze({
|
export const KubernetesDeployRequestMethods = Object.freeze({
|
||||||
REPOSITORY: 'repository',
|
REPOSITORY: 'repository',
|
||||||
STRING: 'string',
|
STRING: 'string',
|
||||||
|
URL: 'url'
|
||||||
});
|
});
|
||||||
|
|
|
@ -111,6 +111,33 @@
|
||||||
</web-editor-form>
|
</web-editor-form>
|
||||||
|
|
||||||
<!-- !editor -->
|
<!-- !editor -->
|
||||||
|
|
||||||
|
<!-- url -->
|
||||||
|
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.URL">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
URL
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<span class="col-sm-12 text-muted small">
|
||||||
|
Indicate the URL to the manifest.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="manifest_url" class="col-sm-1 control-label text-left">URL</label>
|
||||||
|
<div class="col-sm-11">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="ctrl.formValues.ManifestURL"
|
||||||
|
id="manifest_url"
|
||||||
|
placeholder="https://raw.githubusercontent.com/kubernetes/website/main/content/en/examples/controllers/nginx-deployment.yaml"
|
||||||
|
data-cy="k8sAppDeploy-urlFileUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !url -->
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Actions
|
Actions
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import stripAnsi from 'strip-ansi';
|
import stripAnsi from 'strip-ansi';
|
||||||
|
import PortainerError from 'Portainer/error';
|
||||||
|
|
||||||
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods } from 'Kubernetes/models/deploy';
|
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods } from 'Kubernetes/models/deploy';
|
||||||
import { buildOption } from '@/portainer/components/box-selector';
|
import { buildOption } from '@/portainer/components/box-selector';
|
||||||
|
@ -25,6 +26,7 @@ class KubernetesDeployController {
|
||||||
this.methodOptions = [
|
this.methodOptions = [
|
||||||
buildOption('method_repo', 'fab fa-github', 'Git Repository', 'Use a git repository', KubernetesDeployBuildMethods.GIT),
|
buildOption('method_repo', 'fab fa-github', 'Git Repository', 'Use a git repository', KubernetesDeployBuildMethods.GIT),
|
||||||
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', KubernetesDeployBuildMethods.WEB_EDITOR),
|
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', KubernetesDeployBuildMethods.WEB_EDITOR),
|
||||||
|
buildOption('method_url', 'fa fa-globe', 'URL', 'Specify a URL to a file', KubernetesDeployBuildMethods.URL),
|
||||||
buildOption('method_template', 'fa fa-rocket', 'Custom Template', 'Use a custom template', KubernetesDeployBuildMethods.CUSTOM_TEMPLATE),
|
buildOption('method_template', 'fa fa-rocket', 'Custom Template', 'Use a custom template', KubernetesDeployBuildMethods.CUSTOM_TEMPLATE),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -57,10 +59,11 @@ class KubernetesDeployController {
|
||||||
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
|
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
|
||||||
(!this.formValues.RepositoryURL ||
|
(!this.formValues.RepositoryURL ||
|
||||||
!this.formValues.FilePathInRepository ||
|
!this.formValues.FilePathInRepository ||
|
||||||
(this.formValues.RepositoryAuthentication && (!this.formValues.RepositoryUsername || !this.formValues.RepositoryPassword)));
|
(this.formValues.RepositoryAuthentication && (!this.formValues.RepositoryUsername || !this.formValues.RepositoryPassword))) && _.isEmpty(this.formValues.Namespace);
|
||||||
const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent);
|
const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent) && _.isEmpty(this.formValues.Namespace);
|
||||||
|
const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL);
|
||||||
|
|
||||||
return isGitFormInvalid || isWebEditorInvalid || _.isEmpty(this.formValues.Namespace) || this.state.actionInProgress;
|
return isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeFormValues(values) {
|
onChangeFormValues(values) {
|
||||||
|
@ -111,16 +114,25 @@ class KubernetesDeployController {
|
||||||
this.state.actionInProgress = true;
|
this.state.actionInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let method = KubernetesDeployRequestMethods.STRING;
|
let method;
|
||||||
let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
|
let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
|
||||||
|
|
||||||
switch (this.state.BuildMethod) {
|
switch (this.state.BuildMethod) {
|
||||||
case KubernetesDeployBuildMethods.GIT:
|
case this.BuildMethods.GIT:
|
||||||
method = KubernetesDeployRequestMethods.REPOSITORY;
|
method = KubernetesDeployRequestMethods.REPOSITORY;
|
||||||
break;
|
break;
|
||||||
|
case this.BuildMethods.WEB_EDITOR:
|
||||||
|
method = KubernetesDeployRequestMethods.STRING;
|
||||||
|
break;
|
||||||
case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE:
|
case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE:
|
||||||
|
method = KubernetesDeployRequestMethods.STRING;
|
||||||
composeFormat = false;
|
composeFormat = false;
|
||||||
break;
|
break;
|
||||||
|
case this.BuildMethods.URL:
|
||||||
|
method = KubernetesDeployRequestMethods.URL;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new PortainerError('Unable to determine build method');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
|
@ -137,8 +149,10 @@ class KubernetesDeployController {
|
||||||
payload.RepositoryPassword = this.formValues.RepositoryPassword;
|
payload.RepositoryPassword = this.formValues.RepositoryPassword;
|
||||||
}
|
}
|
||||||
payload.FilePathInRepository = this.formValues.FilePathInRepository;
|
payload.FilePathInRepository = this.formValues.FilePathInRepository;
|
||||||
} else {
|
} else if (method === KubernetesDeployRequestMethods.STRING) {
|
||||||
payload.StackFileContent = this.formValues.EditorContent;
|
payload.StackFileContent = this.formValues.EditorContent;
|
||||||
|
} else {
|
||||||
|
payload.ManifestURL = this.formValues.ManifestURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.StackService.kubernetesDeploy(this.endpointId, method, payload);
|
await this.StackService.kubernetesDeploy(this.endpointId, method, payload);
|
||||||
|
|
Loading…
Reference in New Issue