diff --git a/admin-ui/package.json b/admin-ui/package.json index 99f69e1a3..0e3fd93e4 100644 --- a/admin-ui/package.json +++ b/admin-ui/package.json @@ -26,7 +26,7 @@ "jodit-vue": "^2.6.0", "js-cookie": "^3.0.5", "json-schema": "^0.4.0", - "nocloud-proto": "https://github.com/slntopp/nocloud-proto#7e286b620c82aeb0b1b43889d4b7eb67d5bc1410", + "nocloud-proto": "https://github.com/slntopp/nocloud-proto#36f115ce6bf0d0c2615c2c00e970df0c7e450882", "nocloud-ui": "^1.0.16", "nocloudjsrest": "^1.5.17", "nth-check": "^2.1.1", diff --git a/admin-ui/src/components/ServicesProvider/info.vue b/admin-ui/src/components/ServicesProvider/info.vue index e32fd43c2..c4afa8e85 100644 --- a/admin-ui/src/components/ServicesProvider/info.vue +++ b/admin-ui/src/components/ServicesProvider/info.vue @@ -91,13 +91,7 @@ { } }; +const plansParams = computed(() => { + const type = [provider.value.type]; + + if (provider.value.type === "ione") { + type.push("ione-vpn"); + } + + return { + showDeleted: false, + excludeUuids: relatedPlans.value?.map((p) => p.uuid) || [], + filters: { + type, + }, + }; +}); + const spTypes = computed(() => { switch (provider.value.type) { case "ione": diff --git a/admin-ui/src/components/ui/antIcon.vue b/admin-ui/src/components/ui/antIcon.vue index 33f010460..ea8e1d75a 100644 --- a/admin-ui/src/components/ui/antIcon.vue +++ b/admin-ui/src/components/ui/antIcon.vue @@ -19,7 +19,7 @@ export default { } else if (displayName.endsWith("Filled")) { displayName = displayName.replace("Filled", "Fill"); } - const [name, icon] = Object.entries(iconsRes).find( + const [name, icon] = Object.entries(iconsRes)?.find( ([name]) => name === displayName ); this.icon = { name, ...icon }; diff --git a/admin-ui/src/views/PlanPage.vue b/admin-ui/src/views/PlanPage.vue index a9021bbd4..ad7ad2079 100644 --- a/admin-ui/src/views/PlanPage.vue +++ b/admin-ui/src/views/PlanPage.vue @@ -119,7 +119,7 @@ export default { title: "Info", component: () => import("@/views/PlansCreate.vue"), }, - this.plan?.type === "ione" && { + ["ione", "ione-vpn"].includes(this.plan?.type) && { title: "Configuration", component: () => import("@/components/modules/ione/planConfiguration.vue"), diff --git a/admin-ui/src/views/PlansCreate.vue b/admin-ui/src/views/PlansCreate.vue index 340480c98..27a281739 100644 --- a/admin-ui/src/views/PlansCreate.vue +++ b/admin-ui/src/views/PlansCreate.vue @@ -519,6 +519,14 @@ export default { if (matched && matched.length > 1) { if (matched[1] === "ovh") { this.types.push("ovh vps", "ovh dedicated", "ovh cloud"); + } + + if (matched[1] === "ione") { + this.types.push("ione", "ione-vpn",); + } + + if (matched[1] === "empty") { + this.types.push("empty", "vpn"); } else { this.types.push(matched[1]); } @@ -570,6 +578,8 @@ export default { case "ovh cloud": case "opensrs": case "empty": + case "vpn": + case "ione-vpn": case "cpanel": { allowed.push("STATIC"); break; @@ -583,9 +593,19 @@ export default { }, typedSp() { return this.$store.getters["servicesProviders/all"].filter( - (sp) => sp.type == this.plan.type.split(" ")[0] + (sp) => sp.type == this.spType ); }, + spType() { + if (this.plan.type == "vpn") { + return "empty"; + } + + if (this.plan.type == "ione-vpn") { + return "ione"; + } + return this.plan.type.split(" ")[0]; + }, isDeleted() { return this.plan.status === "DEL"; }, diff --git a/admin-ui/src/views/ShowcaseCreate.vue b/admin-ui/src/views/ShowcaseCreate.vue index c5a6a2780..15ce6a062 100644 --- a/admin-ui/src/views/ShowcaseCreate.vue +++ b/admin-ui/src/views/ShowcaseCreate.vue @@ -28,14 +28,14 @@ - + - + + + + + @@ -97,7 +101,8 @@ @@ -128,13 +133,23 @@ const props = defineProps({ realShowcase: {}, isEdit: { type: Boolean, default: false }, }); -// const emits=defineEmits(['input']) - const { realShowcase, isEdit } = toRefs(props); const store = useStore(); const router = useRouter(); +const types = [ + "cloud", + "custom", + "virtual", + "openai", + "vpn", + "ione-vpn", + "domains", + "acronis", + "ssl", +]; + const showcase = ref({ primary: false, title: "", @@ -150,6 +165,9 @@ const showcase = ref({ promo: {}, locations: [], public: true, + meta: { + type: "", + }, }); const currentLang = ref("en"); @@ -168,7 +186,7 @@ const serviceProviders = computed(() => store.getters["servicesProviders/all"]); const locations = computed(() => showcase.value.items.reduce((result, { servicesProvider }, i) => { const { uuid, locations = [] } = - serviceProviders.value.find((sp) => sp.uuid === servicesProvider) ?? {}; + serviceProviders.value?.find((sp) => sp.uuid === servicesProvider) ?? {}; return { ...result, @@ -182,8 +200,8 @@ const locations = computed(() => ); const filteredLocations = computed(() => { - if(isPlansLoading.value || isLoading.value){ - return {} + if (isPlansLoading.value || isLoading.value) { + return {}; } const result = {}; @@ -195,7 +213,7 @@ const filteredLocations = computed(() => { ); if (!plan) return; - result[i] = value.filter(({ type }) => plan.type === type); + result[i] = value.filter(({ type }) => plan.type.split("-")[0] === type); }); return result; @@ -206,7 +224,7 @@ const allLocations = computed(() => (result, [i, locations]) => [ ...result, ...locations.filter(({ id }) => - showcase.value.items[i].locations.find( + showcase.value.items[i].locations?.find( (location) => id === (location.id ?? location) ) ), @@ -220,6 +238,10 @@ watch(realShowcase, () => { showcase.value = JSON.parse(JSON.stringify(realShowcase.value)); showcase.value.newTitle = showcase.value.title; + if (!showcase.value.meta) { + showcase.value.meta = {}; + } + if (!Array.isArray(showcase.value.items)) { showcase.value.items = []; } @@ -253,7 +275,7 @@ const save = async () => { const item = data.items[i]; const locs = value .filter(({ id }) => - item.locations.find((location) => (location.id ?? location) === id) + item.locations?.find((location) => (location.id ?? location) === id) ) .map((location) => ({ ...location, @@ -265,7 +287,7 @@ const save = async () => { })); locs.forEach((location) => { - if (!data.locations.find(({ id }) => id === location.id)) { + if (!data.locations?.find(({ id }) => id === location.id)) { data.locations.push(location); } }); @@ -326,7 +348,7 @@ const getPlan = (sp, uuid) => { const plans = plansBySpMap.value.get(sp) ?? []; if (Array.isArray(plans)) { - return plans.find((plan) => plan.uuid === uuid); + return plans?.find((plan) => plan.uuid === uuid); } }; @@ -339,7 +361,7 @@ const getPlans = (sp) => { const getProviderTitle = (uuid) => { return ( - serviceProviders.value.find((provider) => provider.uuid === uuid)?.title ?? + serviceProviders.value?.find((provider) => provider.uuid === uuid)?.title ?? uuid ); }; @@ -368,7 +390,9 @@ const fetchPlans = async () => { }) ); } finally { - isPlansLoading.value = false; + setTimeout(() => { + isPlansLoading.value = false; + }, 100); } }; diff --git a/admin-ui/src/views/ShowcasePage.vue b/admin-ui/src/views/ShowcasePage.vue index 67446cfc4..96e2d9874 100644 --- a/admin-ui/src/views/ShowcasePage.vue +++ b/admin-ui/src/views/ShowcasePage.vue @@ -96,7 +96,7 @@ onMounted(async () => { isFetchLoading.value = false; } - showcase.value = showcases.value.find((n) => n.uuid == showcaseId.value); + showcase.value = showcases.value?.find((n) => n.uuid == showcaseId.value); document.title = `${showcase.value.title} | NoCloud`; }); diff --git a/go.mod b/go.mod index f47dfb4f5..72177a766 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 github.com/rabbitmq/amqp091-go v1.9.0 github.com/rs/cors v1.10.1 - github.com/slntopp/nocloud-proto v0.0.0-20241226123624-36f115ce6bf0 + github.com/slntopp/nocloud-proto v0.0.0-20250111091130-cef7b71e8db5 github.com/spf13/viper v1.18.2 github.com/stoewer/go-strcase v1.3.0 github.com/stretchr/testify v1.8.4 diff --git a/go.sum b/go.sum index e6b258146..0eca8805c 100644 --- a/go.sum +++ b/go.sum @@ -113,8 +113,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/slntopp/nocloud-proto v0.0.0-20241226123624-36f115ce6bf0 h1:G0cFfUU8f2gR3Tdeq0TIdf+R/ZW5Woup/P7kOAlOXss= -github.com/slntopp/nocloud-proto v0.0.0-20241226123624-36f115ce6bf0/go.mod h1:qPbslPB2J9Q7qm6H9Jaqf/Ysf61YlPL0DUFhIdAEikI= +github.com/slntopp/nocloud-proto v0.0.0-20250111091130-cef7b71e8db5 h1:YER/mdWPB5BpwGxCtFo5uyhN2cD7bULscXen/MXggtc= +github.com/slntopp/nocloud-proto v0.0.0-20250111091130-cef7b71e8db5/go.mod h1:qPbslPB2J9Q7qm6H9Jaqf/Ysf61YlPL0DUFhIdAEikI= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= diff --git a/pkg/billing/consumer.go b/pkg/billing/consumer.go index c88c2d7e6..981dd785e 100644 --- a/pkg/billing/consumer.go +++ b/pkg/billing/consumer.go @@ -329,6 +329,10 @@ func (s *BillingServiceServer) ProcessInstanceCreation(log *zap.Logger, ctx cont return fmt.Errorf("failed getting exchange rate: %w", err) } initCost, _ := s.instances.CalculateInstanceEstimatePrice(instance.Instance, true) + if initCost <= 0 { + log.Info("Skipping creation of 0 invoice for instance with 0 price") + return nil + } _, summary, err := s.promocodes.GetDiscountPriceByInstance(instance.Instance, true) if err != nil { log.Error("Failed to calculate instance cost", zap.Error(err)) diff --git a/pkg/billing/invoices.go b/pkg/billing/invoices.go index 772be3d19..cf49256a2 100644 --- a/pkg/billing/invoices.go +++ b/pkg/billing/invoices.go @@ -475,6 +475,15 @@ func (s *BillingServiceServer) CreateInvoice(ctx context.Context, req *connect.R Requestor: requester, }) + if t.Total <= 0 { + if _, err = s.UpdateInvoiceStatus(ctx, connect.NewRequest(&pb.UpdateInvoiceStatusRequest{ + Uuid: r.GetUuid(), + Status: pb.BillingStatus_PAID, + })); err != nil { + log.Error("Failed to auto-pay 0 or less total invoice", zap.Error(err)) + } + } + resp := connect.NewResponse(r.Invoice) return resp, nil } diff --git a/pkg/eventbus/server.go b/pkg/eventbus/server.go index 936a83ff2..0aefe6d38 100644 --- a/pkg/eventbus/server.go +++ b/pkg/eventbus/server.go @@ -2,6 +2,7 @@ package eventbus import ( "context" + "fmt" "github.com/slntopp/nocloud/pkg/nocloud/rabbitmq" "github.com/spf13/viper" "google.golang.org/protobuf/proto" @@ -107,7 +108,11 @@ func (s *EventBusServer) HandleEventOverride(log *zap.Logger, event *pb.Event) ( log.Debug("Custom events", zap.Any("events", customEvents)) for _, ce := range customEvents { + if ce.Override == event.Key { + if ce.Key == "-" { + return nil, fmt.Errorf("event cancelled by override") + } log.Debug("Event override", zap.Any("old_key", event.Key), zap.Any("new_key", ce.Override)) event.Key = ce.Key return event, nil diff --git a/pkg/instances/server.go b/pkg/instances/server.go index 680f4dc1d..9fcf57581 100644 --- a/pkg/instances/server.go +++ b/pkg/instances/server.go @@ -230,6 +230,50 @@ func (s *InstancesServer) Invoke(ctx context.Context, _req *connect.Request[pb.I return connect.NewResponse(invoke), nil } +func (s *InstancesServer) Start(ctx context.Context, _req *connect.Request[pb.StartRequest]) (*connect.Response[pb.StartResponse], error) { + log := s.log.Named("Start") + req := _req.Msg + requester := ctx.Value(nocloud.NoCloudAccount).(string) + requesterId := driver.NewDocumentID(schema.ACCOUNTS_COL, requester) + log.Debug("Requester", zap.String("id", requester)) + + if !s.ca.HasAccess(ctx, requester, driver.NewDocumentID(schema.NAMESPACES_COL, schema.ROOT_NAMESPACE_KEY), accesspb.Level_ADMIN) { + log.Warn("No root access") + return nil, status.Error(codes.PermissionDenied, "No access rights") + } + + var instance graph.Instance + instance, err := s.ctrl.GetWithAccess(ctx, requesterId, req.GetId()) + if err != nil { + log.Error("Failed to get instance", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) + } + if instance.Instance == nil { + log.Error("Failed to get instance. No object") + return nil, status.Error(codes.Internal, "Failed to obtain instance") + } + if instance.Uuid == "" { + log.Error("Failed to get instance. No uuid") + return nil, status.Error(codes.Internal, "Failed to obtain instance") + } + + if instance.Config == nil { + instance.Config = make(map[string]*structpb.Value) + } + old := proto.Clone(instance.Instance).(*pb.Instance) + + instance.Config["auto_start"] = structpb.NewBoolValue(true) + + if err = s.ctrl.Update(ctx, "", instance.Instance, old); err != nil { + log.Error("Failed to update instance", zap.Error(err)) + return nil, status.Error(codes.Internal, "Failed to update instance") + } + + return connect.NewResponse(&pb.StartResponse{ + Result: true, + }), nil +} + func (s *InstancesServer) Delete(ctx context.Context, _req *connect.Request[pb.DeleteRequest]) (*connect.Response[pb.DeleteResponse], error) { log := s.log.Named("delete") req := _req.Msg @@ -286,13 +330,21 @@ func (s *InstancesServer) Delete(ctx context.Context, _req *connect.Request[pb.D func (s *InstancesServer) Create(ctx context.Context, _req *connect.Request[pb.CreateRequest]) (*connect.Response[pb.CreateResponse], error) { log := s.log.Named("Create") req := _req.Msg - requestor := ctx.Value(nocloud.NoCloudAccount).(string) - requestorId := driver.NewDocumentID(schema.ACCOUNTS_COL, requestor) - log.Debug("Requestor", zap.String("id", requestor)) + requester := ctx.Value(nocloud.NoCloudAccount).(string) + requesterId := driver.NewDocumentID(schema.ACCOUNTS_COL, requester) + log.Debug("Requester", zap.String("id", requester)) + + if req.Promocode != nil && req.GetPromocode() != "" { + ctx = context.WithValue(ctx, graph.CreationPromocodeKey, req.GetPromocode()) + } + + if req.AutoAssign { + return s.createWithAutoAssign(ctx, req, requester) + } igId := driver.NewDocumentID(schema.INSTANCES_GROUPS_COL, req.GetIg()) var ig graph.InstancesGroup - ig, err := s.ig_ctrl.GetWithAccess(ctx, requestorId, igId.Key()) + ig, err := s.ig_ctrl.GetWithAccess(ctx, requesterId, igId.Key()) if err != nil { log.Error("Failed to get instance group", zap.Error(err)) return nil, status.Error(codes.Internal, err.Error()) @@ -315,15 +367,142 @@ func (s *InstancesServer) Create(ctx context.Context, _req *connect.Request[pb.C return nil, status.Error(codes.InvalidArgument, "can't create instance with imported IG") } - if req.Promocode != nil && req.GetPromocode() != "" { - ctx = context.WithValue(ctx, graph.CreationPromocodeKey, req.GetPromocode()) + newId, err := s.ctrl.Create(ctx, igId, sp.GetUuid(), req.GetInstance()) + if err != nil { + log.Error("Failed to create instance", zap.Error(err)) + return nil, status.Error(codes.Internal, err.Error()) } - newId, err := s.ctrl.Create(ctx, igId, sp.GetUuid(), req.GetInstance()) + return connect.NewResponse(&pb.CreateResponse{ + Id: newId, + Result: true, + }), nil +} + +func (s *InstancesServer) createWithAutoAssign(ctx context.Context, req *pb.CreateRequest, requester string) (*connect.Response[pb.CreateResponse], error) { + log := s.log.Named("createWithAutoAssign") + log.Debug("Requester", zap.String("id", requester)) + + if !s.ca.HasAccess(ctx, requester, driver.NewDocumentID(schema.NAMESPACES_COL, schema.ROOT_NAMESPACE_KEY), accesspb.Level_ADMIN) { + log.Warn("No root access") + return nil, status.Error(codes.PermissionDenied, "No access rights") + } + account := req.Account + + acc, err := s.acc_ctrl.Get(ctx, account) + if err != nil { + log.Error("Failed to get account", zap.Error(err)) + return nil, fmt.Errorf("failed to get account: %w", err) + } + + srvResp, err := s.srv_ctrl.List(ctx, requester, &servicespb.ListRequest{ + Filters: map[string]*structpb.Value{ + "account": structpb.NewStringValue(account), + }, + }) + if err != nil { + log.Error("Failed to retrieve services", zap.Error(err)) + return nil, fmt.Errorf("failed to retrieve services: %w", err) + } + services := srvResp.Result + + // Create service for user if it doesn't exist + var srv *servicespb.Service + srvCount := len(services) + if srvCount > 1 { + log.Info("Multiple services found for account. Transferring to first", zap.Int("count", srvCount)) + } + if srvCount == 0 { + log.Info("Account has no services, creating new") + ns, err := s.acc_ctrl.GetNamespace(ctx, graph.Account{ + Account: &rpb.Account{ + Uuid: account, + }, + }) + if err != nil { + log.Error("Failed to get namespace", zap.Error(err)) + return nil, fmt.Errorf("failed to get namespace: %w", err) + } + if !s.ca.HasAccess(ctx, account, ns.ID, accesspb.Level_ADMIN) { + log.Error("Destination account has no access to namespace") + return nil, fmt.Errorf("destination account has no access to namespace") + } + if srv, err = s.srv_ctrl.Create(ctx, &servicespb.Service{ + Version: "1", + Title: "SRV_" + acc.GetTitle(), + }); err != nil { + log.Error("Failed to create service", zap.Error(err)) + return nil, fmt.Errorf("failed to create new service: %w", err) + } + if err = s.srv_ctrl.Join(ctx, srv, &ns, accesspb.Level_ADMIN, roles.OWNER); err != nil { + log.Error("Error while creating access to service", zap.Error(err)) + return nil, fmt.Errorf("failed to create access to new service: %w", err) + } + if err = s.srv_ctrl.SetStatus(ctx, srv, spb.NoCloudStatus_UP); err != nil { + log.Error("Failed to up service", zap.Error(err)) + return nil, fmt.Errorf("failed to up new service: %w", err) + } + } else { + srv = services[0] + } + + // Find instance group or create + sp, err := s.sp_ctrl.Get(ctx, req.GetSp()) + if err != nil { + log.Error("Failed to get service provider", zap.Error(err)) + return nil, fmt.Errorf("failed to obtain service provider: %w", err) + } + existingIGs := srv.GetInstancesGroups() + for _, ig := range existingIGs { + igSp, err := s.ig_ctrl.GetSP(ctx, ig.GetUuid()) + if err != nil { + log.Error("Failed to get IG service provider", zap.Error(err)) + return nil, fmt.Errorf("failed to obtain IG's service provider: %w", err) + } + ig.Sp = &igSp.Uuid + } + + var destIG *pb.InstancesGroup + for _, ig := range existingIGs { + ig.Instances = nil + if ig.Data == nil { + ig.Data = make(map[string]*structpb.Value) + } + if ig.GetSp() == sp.GetUuid() && !ig.Data["imported"].GetBoolValue() { + destIG = ig + break + } + } + + if destIG == nil { + log.Info("Destination instances group not found, creating new") + destIG = &pb.InstancesGroup{ + Type: sp.GetType(), + Title: acc.Title + " - " + sp.GetType(), + } + if err = s.ig_ctrl.Create(ctx, driver.NewDocumentID(schema.SERVICES_COL, srv.GetUuid()), destIG); err != nil { + log.Error("Failed to create instances group", zap.Error(err)) + return nil, fmt.Errorf("failed to create new instances group: %w", err) + } + if err = s.ig_ctrl.Provide(ctx, destIG.GetUuid(), sp.GetUuid()); err != nil { + log.Error("Failed to provide instances group", zap.Error(err)) + return nil, fmt.Errorf("failed to provide instances group for sp: %w", err) + } + if err := s.ig_ctrl.SetStatus(ctx, destIG, spb.NoCloudStatus_UP); err != nil { + log.Error("Failed to up dest instance group", zap.Error(err)) + return nil, fmt.Errorf("failed to up new instances group: %w", err) + } + } + + newId, err := s.ctrl.Create(ctx, driver.NewDocumentID(schema.INSTANCES_GROUPS_COL, destIG.GetUuid()), sp.GetUuid(), req.GetInstance()) if err != nil { log.Error("Failed to create instance", zap.Error(err)) return nil, status.Error(codes.Internal, err.Error()) } + if err := s.ctrl.SetStatus(ctx, &pb.Instance{Uuid: newId}, spb.NoCloudStatus_UP); err != nil { + log.Error("Failed to up created instance", zap.Error(err)) + return nil, fmt.Errorf("failed to up new instance: %w", err) + } return connect.NewResponse(&pb.CreateResponse{ Id: newId,