[ui] Edit container (#7077)

* [api] patch container

* [ui] edit container

* [ui] Initialize endpoint component

* e2e tests

* static ui files
This commit is contained in:
Philippe Martin
2023-09-08 11:48:53 +02:00
committed by GitHub
parent 56b868d16c
commit 1d96115c45
21 changed files with 903 additions and 73 deletions

View File

@@ -649,6 +649,126 @@ paths:
example:
message: "Error deleting the container"
patch:
tags:
- devstate
description: Update a container
parameters:
- name: containerName
in: path
description: Container name to update
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
required:
- name
- image
properties:
image:
description: Container image
type: string
command:
description: Entrypoint of the container
type: array
items: {
type: string
}
args:
description: Args passed to the Container entrypoint
type: array
items: {
type: string
}
env:
description: Environment variables to define
type: array
items:
$ref: '#/components/schemas/Env'
memReq:
description: Requested memory for the deployed container
type: string
memLimit:
description: Memory limit for the deployed container
type: string
cpuReq:
description: Requested CPU for the deployed container
type: string
cpuLimit:
description: CPU limit for the deployed container
type: string
volumeMounts:
description: Volume to mount into the container filesystem
type: array
items:
$ref: '#/components/schemas/VolumeMount'
configureSources:
description: If false, mountSources and sourceMapping values are not considered
type: boolean
mountSources:
description: If true, sources are mounted into container's filesystem
type: boolean
sourceMapping:
description: Specific directory on which to mount sources
type: string
annotation:
description: Annotations added to the resources created for this container
$ref: '#/components/schemas/Annotation'
endpoints:
description: Endpoints exposed by the container
type: array
items:
$ref: '#/components/schemas/Endpoint'
responses:
'200':
description: container was successfully updated
content:
application/json:
schema:
$ref: '#/components/schemas/DevfileContent'
example:
{
"content": "schemaVersion: 2.2.0\n",
"commands": [],
"containers": [],
"images": [],
"resources": [],
"events": {
"preStart": null,
"postStart": null,
"preStop": null,
"postStop": null
},
"metadata": {
"name": "",
"version": "",
"displayName": "",
description": "",
"tags": "",
"architectures": "",
"icon": "",
"globalMemoryLimit": "",
"projectType": "",
"language": "",
"website": "",
"provider": "",
"supportUrl": ""
}
}
'500':
description: Error updating the container
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error updating the container"
/devstate/image:
post:
tags:

View File

@@ -16,6 +16,7 @@ go/model__devstate_command__command_name__move_post_request.go
go/model__devstate_command__command_name__set_default_post_request.go
go/model__devstate_composite_command__command_name__patch_request.go
go/model__devstate_composite_command_post_request.go
go/model__devstate_container__container_name__patch_request.go
go/model__devstate_container_post_request.go
go/model__devstate_events_put_request.go
go/model__devstate_exec_command__command_name__patch_request.go

View File

@@ -41,6 +41,7 @@ type DevstateApiRouter interface {
DevstateCompositeCommandCommandNamePatch(http.ResponseWriter, *http.Request)
DevstateCompositeCommandPost(http.ResponseWriter, *http.Request)
DevstateContainerContainerNameDelete(http.ResponseWriter, *http.Request)
DevstateContainerContainerNamePatch(http.ResponseWriter, *http.Request)
DevstateContainerPost(http.ResponseWriter, *http.Request)
DevstateDevfileDelete(http.ResponseWriter, *http.Request)
DevstateDevfileGet(http.ResponseWriter, *http.Request)
@@ -90,6 +91,7 @@ type DevstateApiServicer interface {
DevstateCompositeCommandCommandNamePatch(context.Context, string, DevstateCompositeCommandCommandNamePatchRequest) (ImplResponse, error)
DevstateCompositeCommandPost(context.Context, DevstateCompositeCommandPostRequest) (ImplResponse, error)
DevstateContainerContainerNameDelete(context.Context, string) (ImplResponse, error)
DevstateContainerContainerNamePatch(context.Context, string, DevstateContainerContainerNamePatchRequest) (ImplResponse, error)
DevstateContainerPost(context.Context, DevstateContainerPostRequest) (ImplResponse, error)
DevstateDevfileDelete(context.Context) (ImplResponse, error)
DevstateDevfileGet(context.Context) (ImplResponse, error)

View File

@@ -110,6 +110,12 @@ func (c *DevstateApiController) Routes() Routes {
"/api/v1/devstate/container/{containerName}",
c.DevstateContainerContainerNameDelete,
},
{
"DevstateContainerContainerNamePatch",
strings.ToUpper("Patch"),
"/api/v1/devstate/container/{containerName}",
c.DevstateContainerContainerNamePatch,
},
{
"DevstateContainerPost",
strings.ToUpper("Post"),
@@ -431,6 +437,32 @@ func (c *DevstateApiController) DevstateContainerContainerNameDelete(w http.Resp
}
// DevstateContainerContainerNamePatch -
func (c *DevstateApiController) DevstateContainerContainerNamePatch(w http.ResponseWriter, r *http.Request) {
params := mux.Vars(r)
containerNameParam := params["containerName"]
devstateContainerContainerNamePatchRequestParam := DevstateContainerContainerNamePatchRequest{}
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&devstateContainerContainerNamePatchRequestParam); err != nil {
c.errorHandler(w, r, &ParsingError{Err: err}, nil)
return
}
if err := AssertDevstateContainerContainerNamePatchRequestRequired(devstateContainerContainerNamePatchRequestParam); err != nil {
c.errorHandler(w, r, err, nil)
return
}
result, err := c.service.DevstateContainerContainerNamePatch(r.Context(), containerNameParam, devstateContainerContainerNamePatchRequestParam)
// If an error occurred, encode the error with the status code
if err != nil {
c.errorHandler(w, r, err, &result)
return
}
// If no error, encode the body and the result code
EncodeJSONResponse(result.Body, &result.Code, w)
}
// DevstateContainerPost -
func (c *DevstateApiController) DevstateContainerPost(w http.ResponseWriter, r *http.Request) {
devstateContainerPostRequestParam := DevstateContainerPostRequest{}

View File

@@ -0,0 +1,98 @@
/*
* odo dev
*
* API interface for 'odo dev'
*
* API version: 0.1
* Generated by: OpenAPI Generator (https://openapi-generator.tech)
*/
package openapi
type DevstateContainerContainerNamePatchRequest struct {
// Container image
Image string `json:"image"`
// Entrypoint of the container
Command []string `json:"command,omitempty"`
// Args passed to the Container entrypoint
Args []string `json:"args,omitempty"`
// Environment variables to define
Env []Env `json:"env,omitempty"`
// Requested memory for the deployed container
MemReq string `json:"memReq,omitempty"`
// Memory limit for the deployed container
MemLimit string `json:"memLimit,omitempty"`
// Requested CPU for the deployed container
CpuReq string `json:"cpuReq,omitempty"`
// CPU limit for the deployed container
CpuLimit string `json:"cpuLimit,omitempty"`
// Volume to mount into the container filesystem
VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"`
// If false, mountSources and sourceMapping values are not considered
ConfigureSources bool `json:"configureSources,omitempty"`
// If true, sources are mounted into container's filesystem
MountSources bool `json:"mountSources,omitempty"`
// Specific directory on which to mount sources
SourceMapping string `json:"sourceMapping,omitempty"`
Annotation Annotation `json:"annotation,omitempty"`
// Endpoints exposed by the container
Endpoints []Endpoint `json:"endpoints,omitempty"`
}
// AssertDevstateContainerContainerNamePatchRequestRequired checks if the required fields are not zero-ed
func AssertDevstateContainerContainerNamePatchRequestRequired(obj DevstateContainerContainerNamePatchRequest) error {
elements := map[string]interface{}{
"image": obj.Image,
}
for name, el := range elements {
if isZero := IsZeroValue(el); isZero {
return &RequiredError{Field: name}
}
}
for _, el := range obj.Env {
if err := AssertEnvRequired(el); err != nil {
return err
}
}
for _, el := range obj.VolumeMounts {
if err := AssertVolumeMountRequired(el); err != nil {
return err
}
}
if err := AssertAnnotationRequired(obj.Annotation); err != nil {
return err
}
for _, el := range obj.Endpoints {
if err := AssertEndpointRequired(el); err != nil {
return err
}
}
return nil
}
// AssertRecurseDevstateContainerContainerNamePatchRequestRequired recursively checks if required fields are not zero-ed in a nested slice.
// Accepts only nested slice of DevstateContainerContainerNamePatchRequest (e.g. [][]DevstateContainerContainerNamePatchRequest), otherwise ErrTypeAssertionError is thrown.
func AssertRecurseDevstateContainerContainerNamePatchRequestRequired(objSlice interface{}) error {
return AssertRecurseInterfaceRequired(objSlice, func(obj interface{}) error {
aDevstateContainerContainerNamePatchRequest, ok := obj.(DevstateContainerContainerNamePatchRequest)
if !ok {
return ErrTypeAssertionError
}
return AssertDevstateContainerContainerNamePatchRequestRequired(aDevstateContainerContainerNamePatchRequest)
})
}

View File

@@ -384,7 +384,33 @@ func (s *DevstateApiService) DevstateCompositeCommandCommandNamePatch(ctx contex
)
if err != nil {
return openapi.Response(http.StatusInternalServerError, openapi.GeneralError{
Message: fmt.Sprintf("Error updating the Image Command: %s", err),
Message: fmt.Sprintf("Error updating the Composite Command: %s", err),
}), nil
}
return openapi.Response(http.StatusOK, newContent), nil
}
func (s *DevstateApiService) DevstateContainerContainerNamePatch(ctx context.Context, name string, patch openapi.DevstateContainerContainerNamePatchRequest) (openapi.ImplResponse, error) {
newContent, err := s.devfileState.PatchContainer(
name,
patch.Image,
patch.Command,
patch.Args,
patch.Env,
patch.MemReq,
patch.MemLimit,
patch.CpuReq,
patch.CpuLimit,
patch.VolumeMounts,
patch.ConfigureSources,
patch.MountSources,
patch.SourceMapping,
patch.Annotation,
patch.Endpoints,
)
if err != nil {
return openapi.Response(http.StatusInternalServerError, openapi.GeneralError{
Message: fmt.Sprintf("Error updating the container: %s", err),
}), nil
}
return openapi.Response(http.StatusOK, newContent), nil

View File

@@ -27,44 +27,6 @@ func (o *DevfileState) AddContainer(
annotation Annotation,
endpoints []Endpoint,
) (DevfileContent, error) {
v1alpha2VolumeMounts := make([]v1alpha2.VolumeMount, 0, len(volumeMounts))
for _, vm := range volumeMounts {
v1alpha2VolumeMounts = append(v1alpha2VolumeMounts, v1alpha2.VolumeMount{
Name: vm.Name,
Path: vm.Path,
})
}
v1alpha2Envs := make([]v1alpha2.EnvVar, 0, len(envs))
for _, env := range envs {
v1alpha2Envs = append(v1alpha2Envs, v1alpha2.EnvVar{
Name: env.Name,
Value: env.Value,
})
}
var annotations *v1alpha2.Annotation
if len(annotation.Deployment) > 0 || len(annotation.Service) > 0 {
annotations = &v1alpha2.Annotation{}
if len(annotation.Deployment) > 0 {
annotations.Deployment = annotation.Deployment
}
if len(annotation.Service) > 0 {
annotations.Service = annotation.Service
}
}
v1alpha2Endpoints := make([]v1alpha2.Endpoint, 0, len(endpoints))
for _, endpoint := range endpoints {
endpoint := endpoint
v1alpha2Endpoints = append(v1alpha2Endpoints, v1alpha2.Endpoint{
Name: endpoint.Name,
TargetPort: int(endpoint.TargetPort),
Exposure: v1alpha2.EndpointExposure(endpoint.Exposure),
Protocol: v1alpha2.EndpointProtocol(endpoint.Protocol),
Secure: &endpoint.Secure,
Path: endpoint.Path,
})
}
container := v1alpha2.Component{
Name: name,
@@ -74,15 +36,15 @@ func (o *DevfileState) AddContainer(
Image: image,
Command: command,
Args: args,
Env: v1alpha2Envs,
Env: tov1alpha2EnvVars(envs),
MemoryRequest: memRequest,
MemoryLimit: memLimit,
CpuRequest: cpuRequest,
CpuLimit: cpuLimit,
VolumeMounts: v1alpha2VolumeMounts,
Annotation: annotations,
VolumeMounts: tov1alpha2VolumeMounts(volumeMounts),
Annotation: tov1alpha2Annotation(annotation),
},
Endpoints: v1alpha2Endpoints,
Endpoints: tov1alpha2Endpoints(endpoints),
},
},
}
@@ -97,6 +59,115 @@ func (o *DevfileState) AddContainer(
return o.GetContent()
}
func (o *DevfileState) PatchContainer(
name string,
image string,
command []string,
args []string,
envs []Env,
memRequest string,
memLimit string,
cpuRequest string,
cpuLimit string,
volumeMounts []VolumeMount,
configureSources bool,
mountSources bool,
sourceMapping string,
annotation Annotation,
endpoints []Endpoint,
) (DevfileContent, error) {
found, err := o.Devfile.Data.GetComponents(common.DevfileOptions{
ComponentOptions: common.ComponentOptions{
ComponentType: v1alpha2.ContainerComponentType,
},
FilterByName: name,
})
if err != nil {
return DevfileContent{}, err
}
if len(found) != 1 {
return DevfileContent{}, fmt.Errorf("%d Container found with name %q", len(found), name)
}
container := found[0]
container.Container.Image = image
container.Container.Command = command
container.Container.Args = args
container.Container.Env = tov1alpha2EnvVars(envs)
container.Container.MemoryRequest = memRequest
container.Container.MemoryLimit = memLimit
container.Container.CpuRequest = cpuRequest
container.Container.CpuLimit = cpuLimit
container.Container.VolumeMounts = tov1alpha2VolumeMounts(volumeMounts)
container.Container.MountSources = nil
container.Container.SourceMapping = ""
if configureSources {
container.Container.MountSources = &mountSources
container.Container.SourceMapping = sourceMapping
}
container.Container.Annotation = tov1alpha2Annotation(annotation)
container.Container.Endpoints = tov1alpha2Endpoints(endpoints)
err = o.Devfile.Data.UpdateComponent(container)
if err != nil {
return DevfileContent{}, err
}
return o.GetContent()
}
func tov1alpha2EnvVars(envs []Env) []v1alpha2.EnvVar {
result := make([]v1alpha2.EnvVar, 0, len(envs))
for _, env := range envs {
result = append(result, v1alpha2.EnvVar{
Name: env.Name,
Value: env.Value,
})
}
return result
}
func tov1alpha2VolumeMounts(volumeMounts []VolumeMount) []v1alpha2.VolumeMount {
result := make([]v1alpha2.VolumeMount, 0, len(volumeMounts))
for _, vm := range volumeMounts {
result = append(result, v1alpha2.VolumeMount{
Name: vm.Name,
Path: vm.Path,
})
}
return result
}
func tov1alpha2Annotation(annotation Annotation) *v1alpha2.Annotation {
var result *v1alpha2.Annotation
if len(annotation.Deployment) > 0 || len(annotation.Service) > 0 {
result = &v1alpha2.Annotation{}
if len(annotation.Deployment) > 0 {
result.Deployment = annotation.Deployment
}
if len(annotation.Service) > 0 {
result.Service = annotation.Service
}
}
return result
}
func tov1alpha2Endpoints(endpoints []Endpoint) []v1alpha2.Endpoint {
result := make([]v1alpha2.Endpoint, 0, len(endpoints))
for _, endpoint := range endpoints {
endpoint := endpoint
result = append(result, v1alpha2.Endpoint{
Name: endpoint.Name,
TargetPort: int(endpoint.TargetPort),
Exposure: v1alpha2.EndpointExposure(endpoint.Exposure),
Protocol: v1alpha2.EndpointProtocol(endpoint.Protocol),
Secure: &endpoint.Secure,
Path: endpoint.Path,
})
}
return result
}
func (o *DevfileState) DeleteContainer(name string) (DevfileContent, error) {
err := o.checkContainerUsed(name)

View File

@@ -649,6 +649,126 @@ paths:
example:
message: "Error deleting the container"
patch:
tags:
- devstate
description: Update a container
parameters:
- name: containerName
in: path
description: Container name to update
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
type: object
required:
- name
- image
properties:
image:
description: Container image
type: string
command:
description: Entrypoint of the container
type: array
items: {
type: string
}
args:
description: Args passed to the Container entrypoint
type: array
items: {
type: string
}
env:
description: Environment variables to define
type: array
items:
$ref: '#/components/schemas/Env'
memReq:
description: Requested memory for the deployed container
type: string
memLimit:
description: Memory limit for the deployed container
type: string
cpuReq:
description: Requested CPU for the deployed container
type: string
cpuLimit:
description: CPU limit for the deployed container
type: string
volumeMounts:
description: Volume to mount into the container filesystem
type: array
items:
$ref: '#/components/schemas/VolumeMount'
configureSources:
description: If false, mountSources and sourceMapping values are not considered
type: boolean
mountSources:
description: If true, sources are mounted into container's filesystem
type: boolean
sourceMapping:
description: Specific directory on which to mount sources
type: string
annotation:
description: Annotations added to the resources created for this container
$ref: '#/components/schemas/Annotation'
endpoints:
description: Endpoints exposed by the container
type: array
items:
$ref: '#/components/schemas/Endpoint'
responses:
'200':
description: container was successfully updated
content:
application/json:
schema:
$ref: '#/components/schemas/DevfileContent'
example:
{
"content": "schemaVersion: 2.2.0\n",
"commands": [],
"containers": [],
"images": [],
"resources": [],
"events": {
"preStart": null,
"postStart": null,
"preStop": null,
"postStop": null
},
"metadata": {
"name": "",
"version": "",
"displayName": "",
description": "",
"tags": "",
"architectures": "",
"icon": "",
"globalMemoryLimit": "",
"projectType": "",
"language": "",
"website": "",
"provider": "",
"supportUrl": ""
}
}
'500':
description: Error updating the container
content:
application/json:
schema:
$ref: '#/components/schemas/GeneralError'
example:
message: "Error updating the container"
/devstate/image:
post:
tags:

View File

@@ -11,6 +11,6 @@
<body class="mat-typography">
<div id="loading">Loading, please wait...</div>
<app-root></app-root>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.30e0dd4eb9375796.js" type="module"></script>
<script src="runtime.1289ea0acffcdc5e.js" type="module"></script><script src="polyfills.8b3b37cedaf377c3.js" type="module"></script><script src="main.d81b67faa7ca223c.js" type="module"></script>
</body></html>

File diff suppressed because one or more lines are too long

View File

@@ -116,6 +116,118 @@ describe('devfile editor spec', () => {
.should('contain.text', 'volume2');
});
it.only('displays a modified container', () => {
cy.init();
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-name').type('volume1');
cy.getByDataCy('volume-size').type('512Mi');
cy.getByDataCy('volume-ephemeral').click();
cy.getByDataCy('volume-create').click();
cy.selectTab(TAB_CONTAINERS);
cy.getByDataCy('container-name').type('created-container');
cy.getByDataCy('container-image').type('an-image');
cy.getByDataCy('container-env-add').click();
cy.getByDataCy('container-env-name-0').type("VAR1");
cy.getByDataCy('container-env-value-0').type("val1");
cy.getByDataCy('container-env-plus').click();
cy.getByDataCy('container-env-name-1').type("VAR2");
cy.getByDataCy('container-env-value-1').type("val2");
cy.getByDataCy('container-env-plus').click();
cy.getByDataCy('container-env-name-2').type("VAR3");
cy.getByDataCy('container-env-value-2').type("val3");
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-0').type("/mnt/vol1", {force: true});
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('endpoints-add').click();
cy.getByDataCy('endpoint-name-0').type("ep1");
cy.getByDataCy('endpoint-targetPort-0').type("4001");
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-1').type("/mnt/vol2", {force: true});
cy.getByDataCy('volume-mount-name-1').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume2');
cy.getByDataCy('volume-create').click();
cy.getByDataCy('container-more-params').click();
cy.getByDataCy('container-deploy-anno-add').click();
cy.getByDataCy('container-deploy-anno-name-0').type("DEPANNO1");
cy.getByDataCy('container-deploy-anno-value-0').type("depval1");
cy.getByDataCy('container-deploy-anno-plus').click();
cy.getByDataCy('container-deploy-anno-name-1').type("DEPANNO2");
cy.getByDataCy('container-deploy-anno-value-1').type("depval2");
cy.getByDataCy('container-svc-anno-add').click();
cy.getByDataCy('container-svc-anno-name-0').type("SVCANNO1");
cy.getByDataCy('container-svc-anno-value-0').type("svcval1");
cy.getByDataCy('container-svc-anno-plus').click();
cy.getByDataCy('container-svc-anno-name-1').type("SVCANNO2");
cy.getByDataCy('container-svc-anno-value-1').type("svcval2");
cy.getByDataCy('container-create').click();
cy.getByDataCy('container-edit').click();
cy.getByDataCy('container-image').type('{selectAll}{del}another-image');
cy.getByDataCy('container-env-plus').click();
cy.getByDataCy('container-env-name-3').type("VAR4");
cy.getByDataCy('container-env-value-3').type("val4");
cy.getByDataCy('volume-mount-path-0').type("{selectAll}{del}/mnt/other/vol1", {force: true});
cy.getByDataCy('volume-mount-name-0').click().get('mat-option').contains('volume1').click();
cy.getByDataCy('endpoint-targetPort-0').type("{selectAll}{del}4002");
cy.getByDataCy('volume-mount-add').click();
cy.getByDataCy('volume-mount-path-2').type("/mnt/vol3", {force: true});
cy.getByDataCy('volume-mount-name-2').click().get('mat-option').contains('(New Volume)').click();
cy.getByDataCy('volume-name').type('volume3');
cy.getByDataCy('volume-create').click();
cy.getByDataCy('container-more-params').click();
cy.getByDataCy('container-deploy-anno-name-0').type("{selectAll}{del}DEPANNO1b");
cy.getByDataCy('container-deploy-anno-value-0').type("{selectAll}{del}depval1b");
cy.getByDataCy('container-deploy-anno-plus').click();
cy.getByDataCy('container-deploy-anno-name-2').type("DEPANNO3");
cy.getByDataCy('container-deploy-anno-value-2').type("depval3");
cy.getByDataCy('container-svc-anno-name-0').type("{selectAll}{del}SVCANNO1b");
cy.getByDataCy('container-svc-anno-value-0').type("{selectAll}{del}svcval1b");
cy.getByDataCy('container-svc-anno-plus').click();
cy.getByDataCy('container-svc-anno-name-2').type("SVCANNO3");
cy.getByDataCy('container-svc-anno-value-2').type("svcval3");
cy.getByDataCy('container-save').click();
cy.getByDataCy('container-info').first()
.should('contain.text', 'another-image')
.should('contain.text', 'VAR1: val1')
.should('contain.text', 'VAR2: val2')
.should('contain.text', 'VAR3: val3')
.should('contain.text', 'VAR4: val4')
.should('contain.text', 'volume1')
.should('contain.text', '/mnt/other/vol1')
.should('contain.text', 'volume2')
.should('contain.text', '/mnt/vol2')
.should('not.contain.text', 'Mount Sources')
.should('contain.text', 'ep1')
.should('contain.text', '4002')
.should('contain.text', 'Deployment Annotations')
.should('contain.text', 'DEPANNO1b: depval1b')
.should('contain.text', 'DEPANNO2: depval2')
.should('contain.text', 'DEPANNO3: depval3')
.should('contain.text', 'Service Annotations')
.should('contain.text', 'SVCANNO1b: svcval1b')
.should('contain.text', 'SVCANNO2: svcval2')
.should('contain.text', 'SVCANNO3: svcval3');
cy.selectTab(TAB_VOLUMES);
cy.getByDataCy('volume-info').eq(1)
.should('contain.text', 'volume2');
});
it('displays a created container with source configuration', () => {
cy.init();

View File

@@ -25,6 +25,7 @@ model/devstateCommandCommandNameMovePostRequest.ts
model/devstateCommandCommandNameSetDefaultPostRequest.ts
model/devstateCompositeCommandCommandNamePatchRequest.ts
model/devstateCompositeCommandPostRequest.ts
model/devstateContainerContainerNamePatchRequest.ts
model/devstateContainerPostRequest.ts
model/devstateDevfilePutRequest.ts
model/devstateEventsPutRequest.ts

View File

@@ -35,6 +35,8 @@ import { DevstateCompositeCommandCommandNamePatchRequest } from '../model/devsta
// @ts-ignore
import { DevstateCompositeCommandPostRequest } from '../model/devstateCompositeCommandPostRequest';
// @ts-ignore
import { DevstateContainerContainerNamePatchRequest } from '../model/devstateContainerContainerNamePatchRequest';
// @ts-ignore
import { DevstateContainerPostRequest } from '../model/devstateContainerPostRequest';
// @ts-ignore
import { DevstateDevfilePutRequest } from '../model/devstateDevfilePutRequest';
@@ -769,6 +771,75 @@ export class DevstateService {
);
}
/**
* Update a container
* @param containerName Container name to update
* @param devstateContainerContainerNamePatchRequest
* @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body.
* @param reportProgress flag to report request and response progress.
*/
public devstateContainerContainerNamePatch(containerName: string, devstateContainerContainerNamePatchRequest?: DevstateContainerContainerNamePatchRequest, observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<DevfileContent>;
public devstateContainerContainerNamePatch(containerName: string, devstateContainerContainerNamePatchRequest?: DevstateContainerContainerNamePatchRequest, observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpResponse<DevfileContent>>;
public devstateContainerContainerNamePatch(containerName: string, devstateContainerContainerNamePatchRequest?: DevstateContainerContainerNamePatchRequest, observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<HttpEvent<DevfileContent>>;
public devstateContainerContainerNamePatch(containerName: string, devstateContainerContainerNamePatchRequest?: DevstateContainerContainerNamePatchRequest, observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext}): Observable<any> {
if (containerName === null || containerName === undefined) {
throw new Error('Required parameter containerName was null or undefined when calling devstateContainerContainerNamePatch.');
}
let localVarHeaders = this.defaultHeaders;
let localVarHttpHeaderAcceptSelected: string | undefined = options && options.httpHeaderAccept;
if (localVarHttpHeaderAcceptSelected === undefined) {
// to determine the Accept header
const httpHeaderAccepts: string[] = [
'application/json'
];
localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts);
}
if (localVarHttpHeaderAcceptSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Accept', localVarHttpHeaderAcceptSelected);
}
let localVarHttpContext: HttpContext | undefined = options && options.context;
if (localVarHttpContext === undefined) {
localVarHttpContext = new HttpContext();
}
// to determine the Content-Type header
const consumes: string[] = [
'application/json'
];
const httpContentTypeSelected: string | undefined = this.configuration.selectHeaderContentType(consumes);
if (httpContentTypeSelected !== undefined) {
localVarHeaders = localVarHeaders.set('Content-Type', httpContentTypeSelected);
}
let responseType_: 'text' | 'json' | 'blob' = 'json';
if (localVarHttpHeaderAcceptSelected) {
if (localVarHttpHeaderAcceptSelected.startsWith('text')) {
responseType_ = 'text';
} else if (this.configuration.isJsonMime(localVarHttpHeaderAcceptSelected)) {
responseType_ = 'json';
} else {
responseType_ = 'blob';
}
}
let localVarPath = `/devstate/container/${this.configuration.encodeParam({name: "containerName", value: containerName, in: "path", style: "simple", explode: false, dataType: "string", dataFormat: undefined})}`;
return this.httpClient.request<DevfileContent>('patch', `${this.configuration.basePath}${localVarPath}`,
{
context: localVarHttpContext,
body: devstateContainerContainerNamePatchRequest,
responseType: <any>responseType_,
withCredentials: this.configuration.withCredentials,
headers: localVarHeaders,
observe: observe,
reportProgress: reportProgress
}
);
}
/**
* Add a new container to the Devfile
* @param devstateContainerPostRequest

View File

@@ -0,0 +1,73 @@
/**
* odo dev
* API interface for \'odo dev\'
*
* The version of the OpenAPI document: 0.1
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import { Endpoint } from './endpoint';
import { VolumeMount } from './volumeMount';
import { Env } from './env';
import { Annotation } from './annotation';
export interface DevstateContainerContainerNamePatchRequest {
/**
* Container image
*/
image: string;
/**
* Entrypoint of the container
*/
command?: Array<string>;
/**
* Args passed to the Container entrypoint
*/
args?: Array<string>;
/**
* Environment variables to define
*/
env?: Array<Env>;
/**
* Requested memory for the deployed container
*/
memReq?: string;
/**
* Memory limit for the deployed container
*/
memLimit?: string;
/**
* Requested CPU for the deployed container
*/
cpuReq?: string;
/**
* CPU limit for the deployed container
*/
cpuLimit?: string;
/**
* Volume to mount into the container filesystem
*/
volumeMounts?: Array<VolumeMount>;
/**
* If false, mountSources and sourceMapping values are not considered
*/
configureSources?: boolean;
/**
* If true, sources are mounted into container\'s filesystem
*/
mountSources?: boolean;
/**
* Specific directory on which to mount sources
*/
sourceMapping?: string;
annotation?: Annotation;
/**
* Endpoints exposed by the container
*/
endpoints?: Array<Endpoint>;
}

View File

@@ -15,6 +15,7 @@ export * from './devstateCommandCommandNameMovePostRequest';
export * from './devstateCommandCommandNameSetDefaultPostRequest';
export * from './devstateCompositeCommandCommandNamePatchRequest';
export * from './devstateCompositeCommandPostRequest';
export * from './devstateContainerContainerNamePatchRequest';
export * from './devstateContainerPostRequest';
export * from './devstateDevfilePutRequest';
export * from './devstateEventsPutRequest';

View File

@@ -1,9 +1,6 @@
import { Component, forwardRef } from '@angular/core';
import { AbstractControl, ControlValueAccessor, FormArray, FormControl, FormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms';
interface Endpoint {
}
import { Endpoint } from 'src/app/api-gen';
@Component({
selector: 'app-endpoints',
@@ -35,23 +32,29 @@ export class EndpointsComponent implements ControlValueAccessor, Validator {
});
}
newEndpoint(): FormGroup {
newEndpoint(ep: Endpoint): FormGroup {
return new FormGroup({
name: new FormControl("", [Validators.required]),
targetPort: new FormControl("", [Validators.required, Validators.pattern("^[0-9]*$")]),
exposure: new FormControl(""),
path: new FormControl(""),
protocol: new FormControl(""),
secure: new FormControl(false),
name: new FormControl(ep.name, [Validators.required]),
targetPort: new FormControl(ep.targetPort, [Validators.required, Validators.pattern("^[0-9]*$")]),
exposure: new FormControl(ep.exposure),
path: new FormControl(ep.path),
protocol: new FormControl(ep.protocol),
secure: new FormControl(ep.secure),
});
}
addEndpoint() {
this.form.push(this.newEndpoint());
this.form.push(this.newEndpoint({
name: '',
targetPort: 0,
}));
}
/* ControlValueAccessor implementation */
writeValue(value: Endpoint[]) {
value.forEach(ep => {
this.form.push(this.newEndpoint(ep));
});
}
registerOnChange(onChange: any) {

View File

@@ -1,5 +1,6 @@
<div class="main">
<h2>Add a new container</h2>
<h2 *ngIf="!container">Add a new container</h2>
<h2 *ngIf="container">Edit container <i>{{container.name}}</i></h2>
<div class="description">A Container is used to execute shell commands into a specific environment. The entrypoint of the container must be a non-terminating command. You can use an image pulled from a registry or an image built by an Image command.</div>
<form [formGroup]="form">
<mat-form-field appearance="outline" class="mid-width">
@@ -86,7 +87,8 @@
</form>
<div class="buttonbar">
<button data-cy="container-create" [disabled]="form.invalid" mat-flat-button color="primary" matTooltip="create new container" (click)="create()">Create</button>
<button *ngIf="!container" data-cy="container-create" [disabled]="form.invalid" mat-flat-button color="primary" matTooltip="create new container" (click)="create()">Create</button>
<button *ngIf="container" data-cy="container-save" [disabled]="form.invalid" mat-flat-button color="primary" matTooltip="save container" (click)="save()">Save</button>
<button *ngIf="cancelable" mat-flat-button (click)="cancel()">Cancel</button>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { Component, EventEmitter, Input, Output, SimpleChanges } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { PATTERN_COMPONENT_ID } from '../patterns';
import { DevstateService } from 'src/app/services/devstate.service';
@@ -18,8 +18,11 @@ export interface ToCreate {
export class ContainerComponent {
@Input() volumeNames: string[] = [];
@Input() cancelable: boolean = false;
@Input() container: Container | undefined;
@Output() canceled = new EventEmitter<void>();
@Output() created = new EventEmitter<ToCreate>();
@Output() saved = new EventEmitter<ToCreate>();
form: FormGroup;
@@ -79,17 +82,27 @@ export class ContainerComponent {
}
}
create() {
this.telemetry.track("[ui] create container");
const toObject = (o: {name: string, value: string}[]) => {
toObject(o: {name: string, value: string}[]) {
if (o == null) {
return {};
}
return o.reduce((acc: any, val: {name: string, value: string}) => { acc[val.name] = val.value; return acc; }, {});
};
fromObject(o: any) {
if (o == null) {
return [];
}
return Object.keys(o).map(k => ({ name: k, value: o[k]}));
}
create() {
this.telemetry.track("[ui] create container");
const container = this.form.value;
container.annotation = {
deployment: toObject(container.deployAnnotations),
service: toObject(container.svcAnnotations),
deployment: this.toObject(container.deployAnnotations),
service: this.toObject(container.svcAnnotations),
};
this.created.emit({
container: this.form.value,
@@ -97,10 +110,43 @@ export class ContainerComponent {
});
}
save() {
this.telemetry.track("[ui] edit container");
const newValue = this.form.value;
newValue.name = this.container?.name;
newValue.annotation = {
deployment: this.toObject(newValue.deployAnnotations),
service: this.toObject(newValue.svcAnnotations),
};
this.saved.emit({
container: newValue,
volumes: this.volumesToCreate,
});
}
cancel() {
this.canceled.emit();
}
ngOnChanges(changes: SimpleChanges) {
if (!changes['container']) {
return;
}
const container = changes['container'].currentValue;
if (container == undefined) {
this.form.get('name')?.enable();
} else {
this.form.reset();
this.form.patchValue(container);
this.form.get('name')?.disable();
if (this.form.get('sourceMapping')?.value != '') {
this.form.get('_specificDir')?.setValue(true);
}
this.form.get('deployAnnotations')?.setValue(this.fromObject(container.annotation.deployment));
this.form.get('svcAnnotations')?.setValue(this.fromObject(container.annotation.service));
}
}
onCreateNewVolume(v: Volume) {
this.volumesToCreate.push(v);
}

View File

@@ -36,6 +36,28 @@ export class DevstateService {
});
}
saveContainer(container: Container): Observable<DevfileContent> {
return this.http.patch<DevfileContent>(this.base+"/container/"+container.name, {
image: container.image,
command: container.command,
args: container.args,
env: container.env,
memReq: container.memoryRequest,
memLimit: container.memoryLimit,
cpuReq: container.cpuRequest,
cpuLimit: container.cpuLimit,
volumeMounts: container.volumeMounts,
configureSources: container.configureSources,
mountSources: container.mountSources,
sourceMapping: container.sourceMapping,
annotation: {
deployment: container.annotation.deployment,
service: container.annotation.service
},
endpoints: container.endpoints,
});
}
addImage(image: Image): Observable<DevfileContent> {
return this.http.post<DevfileContent>(this.base+"/image", {
name: image.name,

View File

@@ -99,21 +99,24 @@
<mat-card-actions>
<button mat-button color="warn" (click)="delete(container.name)">Delete</button>
<button data-cy="container-edit" mat-button (click)="edit(container)">Edit</button>
</mat-card-actions>
</mat-card>
<app-container
*ngIf="forceDisplayAdd || containers == undefined || containers.length == 0"
*ngIf="forceDisplayForm || containers == undefined || containers.length == 0"
[volumeNames]="volumeNames ?? []"
[cancelable]="forceDisplayAdd"
[cancelable]="forceDisplayForm"
(canceled)="undisplayAddForm()"
(created)="onCreated($event)"
[container]="editingContainer"
(saved)="onSaved($event)"
></app-container>
</div>
<ng-container *ngIf="!forceDisplayAdd && containers != undefined && containers.length > 0">
<ng-container *ngIf="!forceDisplayForm && containers != undefined && containers.length > 0">
<button data-cy="add" class="fab" mat-fab color="primary" (click)="displayAddForm()">
<mat-icon class="material-icons-outlined">add</mat-icon>
</button>

View File

@@ -11,10 +11,12 @@ import { ToCreate } from 'src/app/forms/container/container.component';
})
export class ContainersComponent implements OnInit {
forceDisplayAdd: boolean = false;
forceDisplayForm: boolean = false;
containers: Container[] | undefined = [];
volumeNames: string[] | undefined = [];
editingContainer: Container | undefined;
constructor(
private state: StateService,
private devstate: DevstateService,
@@ -28,19 +30,24 @@ export class ContainersComponent implements OnInit {
if (this.containers == null) {
return
}
that.forceDisplayAdd = false;
that.forceDisplayForm = false;
});
}
displayAddForm() {
this.forceDisplayAdd = true;
this.editingContainer = undefined;
this.displayForm();
}
displayForm() {
this.forceDisplayForm = true;
setTimeout(() => {
this.scrollToBottom();
}, 0);
}
undisplayAddForm() {
this.forceDisplayAdd = false;
this.forceDisplayForm = false;
}
delete(name: string) {
@@ -73,6 +80,11 @@ export class ContainersComponent implements OnInit {
});
}
edit(container: Container) {
this.editingContainer = container;
this.displayForm();
}
onCreated(toCreate: ToCreate) {
const container = toCreate.container;
this.createVolumes(toCreate.volumes, 0, () => {
@@ -86,7 +98,21 @@ export class ContainersComponent implements OnInit {
}
});
});
}
onSaved(toCreate: ToCreate) {
const container = toCreate.container;
this.createVolumes(toCreate.volumes, 0, () => {
const result = this.devstate.saveContainer(container);
result.subscribe({
next: value => {
this.state.changeDevfileYaml(value);
},
error: error => {
alert(error.error.message);
}
});
});
}
scrollToBottom() {