main 6af0a61

installation_service_test.go RawHistoryFolder

1package services
2
3import (
4	"context"
5	"net/http"
6	"testing"
7
8	"github.com/bnema/archup/internal/application/commands"
9	"github.com/bnema/archup/internal/application/handlers"
10	"github.com/bnema/archup/internal/domain/bootloader"
11	"github.com/bnema/archup/internal/domain/disk"
12	"github.com/bnema/archup/internal/domain/packages"
13	"github.com/bnema/archup/internal/domain/ports"
14	"github.com/bnema/archup/internal/domain/ports/mocks"
15	"go.uber.org/mock/gomock"
16)
17
18func newMockResponse(ctrl *gomock.Controller, statusCode int, body []byte) ports.Response {
19	resp := mocks.NewMockResponse(ctrl)
20	resp.EXPECT().StatusCode().Return(statusCode).AnyTimes()
21	resp.EXPECT().Body().Return(body).AnyTimes()
22	resp.EXPECT().Close().Return(nil).AnyTimes()
23	return resp
24}
25
26func createTestService(ctrl *gomock.Controller) *InstallationService {
27	mockFS := mocks.NewMockFileSystem(ctrl)
28	mockExec := mocks.NewMockCommandExecutor(ctrl)
29	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
30	mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
31	mockHTTP := mocks.NewMockHTTPClient(ctrl)
32	mockRepo := mocks.NewMockInstallationRepository(ctrl)
33	mockLogger := mocks.NewMockLogger(ctrl)
34
35	mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
36	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
37	mockLogger.EXPECT().Warn(gomock.Any()).AnyTimes()
38	mockLogger.EXPECT().Warn(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
39	mockLogger.EXPECT().Error(gomock.Any(), gomock.Any()).AnyTimes()
40	mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
41	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
42	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
43	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
44	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
45	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
46	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
47	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
48	mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
49	mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
50	mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
51	mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
52	mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
53	mockFS.EXPECT().Stat(gomock.Any()).Return(nil, nil).AnyTimes()
54	mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
55	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
56	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
57	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
58	mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
59	mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
60
61	bootstrapHandler := handlers.NewBootstrapHandler(mockFS, mockHTTP, mockLogger, "https://github.com/bnema/archup", "https://raw.githubusercontent.com/bnema/archup/dev", "dev")
62	preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
63	partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
64	baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
65	configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
66	bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
67	reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
68	postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
69
70	return NewInstallationService(
71		mockRepo,
72		mockLogger,
73		bootstrapHandler,
74		preflightHandler,
75		partitionHandler,
76		baseHandler,
77		configHandler,
78		bootloaderHandler,
79		reposHandler,
80		postInstallHandler,
81	)
82}
83
84func TestInstallationService_Start(t *testing.T) {
85	ctrl := gomock.NewController(t)
86	defer ctrl.Finish()
87
88	service := createTestService(ctrl)
89	defer func() {
90		if err := service.Close(); err != nil {
91			t.Fatalf("expected no error, got %v", err)
92		}
93	}()
94
95	ctx := context.Background()
96	err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none")
97
98	if err != nil {
99		t.Fatalf("expected no error, got %v", err)
100	}
101
102	if service.installAgg == nil {
103		t.Fatal("expected installation to be created")
104	}
105
106	if service.installAgg.Hostname() != "myarch" {
107		t.Errorf("expected hostname myarch, got %s", service.installAgg.Hostname())
108	}
109}
110
111func TestInstallationService_Start_InvalidHostname(t *testing.T) {
112	ctrl := gomock.NewController(t)
113	defer ctrl.Finish()
114
115	mockFS := mocks.NewMockFileSystem(ctrl)
116	mockExec := mocks.NewMockCommandExecutor(ctrl)
117	mockChrExec := mocks.NewMockChrootExecutor(ctrl)
118	mockScriptExec := mocks.NewMockScriptExecutor(ctrl)
119	mockHTTP := mocks.NewMockHTTPClient(ctrl)
120	mockRepo := mocks.NewMockInstallationRepository(ctrl)
121	mockLogger := mocks.NewMockLogger(ctrl)
122
123	mockLogger.EXPECT().Info(gomock.Any()).AnyTimes()
124	mockLogger.EXPECT().Info(gomock.Any(), gomock.Any()).AnyTimes()
125	mockLogger.EXPECT().Error(gomock.Any(), gomock.Any(), gomock.Any()).Times(1)
126	mockLogger.EXPECT().LogPath().Return("/var/log/archup-install.log").AnyTimes()
127	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
128	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
129	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
130	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
131	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
132	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
133	mockExec.EXPECT().Execute(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
134	mockFS.EXPECT().Exists(gomock.Any()).Return(true, nil).AnyTimes()
135	mockFS.EXPECT().ReadFile(gomock.Any()).Return([]byte("HOOKS=(base)\n"), nil).AnyTimes()
136	mockFS.EXPECT().WriteFile(gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
137	mockFS.EXPECT().MkdirAll(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
138	mockFS.EXPECT().Chmod(gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
139	mockFS.EXPECT().Stat(gomock.Any()).Return(nil, nil).AnyTimes()
140	mockHTTP.EXPECT().Get(gomock.Any()).Return(newMockResponse(ctrl, http.StatusOK, []byte("content")), nil).AnyTimes()
141	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
142	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
143	mockChrExec.EXPECT().ExecuteInChroot(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return([]byte{}, nil).AnyTimes()
144	mockChrExec.EXPECT().ExecuteInChrootWithStdin(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
145	mockChrExec.EXPECT().ChrootSystemctl(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
146
147	bootstrapHandler := handlers.NewBootstrapHandler(mockFS, mockHTTP, mockLogger, "https://github.com/bnema/archup", "https://raw.githubusercontent.com/bnema/archup/dev", "dev")
148	preflightHandler := handlers.NewPreflightHandler(mockFS, mockExec, mockLogger)
149	partitionHandler := handlers.NewPartitionHandler(mockExec, mockLogger)
150	baseHandler := handlers.NewInstallBaseHandler(mockFS, mockExec, mockChrExec, mockLogger)
151	configHandler := handlers.NewConfigureSystemHandler(mockFS, mockChrExec, mockLogger)
152	bootloaderHandler := handlers.NewBootloaderHandler(mockFS, mockExec, mockChrExec, mockLogger)
153	reposHandler := handlers.NewReposHandler(mockFS, mockChrExec, mockLogger)
154	postInstallHandler := handlers.NewPostInstallHandler(mockFS, mockHTTP, mockChrExec, mockScriptExec, mockLogger, "https://raw.githubusercontent.com/bnema/archup/dev")
155
156	service := NewInstallationService(mockRepo, mockLogger, bootstrapHandler, preflightHandler, partitionHandler, baseHandler, configHandler, bootloaderHandler, reposHandler, postInstallHandler)
157	defer func() {
158		if err := service.Close(); err != nil {
159			t.Fatalf("expected no error, got %v", err)
160		}
161	}()
162
163	ctx := context.Background()
164	err := service.Start(ctx, "", "testuser", "/dev/sda", "none")
165
166	if err == nil {
167		t.Error("expected error for invalid hostname")
168	}
169
170	if service.installAgg != nil {
171		t.Error("expected installation not to be created")
172	}
173}
174
175func TestInstallationService_GetStatus_NotStarted(t *testing.T) {
176	ctrl := gomock.NewController(t)
177	defer ctrl.Finish()
178
179	service := createTestService(ctrl)
180	defer func() {
181		if err := service.Close(); err != nil {
182			t.Fatalf("expected no error, got %v", err)
183		}
184	}()
185
186	status := service.GetStatus()
187
188	if status.State != "NotStarted" {
189		t.Errorf("expected state NotStarted, got %s", status.State)
190	}
191}
192
193func TestInstallationService_GetStatus_Started(t *testing.T) {
194	ctrl := gomock.NewController(t)
195	defer ctrl.Finish()
196
197	service := createTestService(ctrl)
198	defer func() {
199		if err := service.Close(); err != nil {
200			t.Fatalf("expected no error, got %v", err)
201		}
202	}()
203
204	ctx := context.Background()
205	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
206		t.Fatalf("expected no error, got %v", err)
207	}
208
209	status := service.GetStatus()
210
211	if status.State != "PreflightChecks" {
212		t.Errorf("expected state PreflightChecks, got %s", status.State)
213	}
214
215	if status.Hostname != "myarch" {
216		t.Errorf("expected hostname myarch, got %s", status.Hostname)
217	}
218
219	if status.Username != "testuser" {
220		t.Errorf("expected username testuser, got %s", status.Username)
221	}
222}
223
224func TestInstallationService_RunPreflight_NotRoot(t *testing.T) {
225	ctrl := gomock.NewController(t)
226	defer ctrl.Finish()
227
228	service := createTestService(ctrl)
229	defer func() {
230		if err := service.Close(); err != nil {
231			t.Fatalf("expected no error, got %v", err)
232		}
233	}()
234
235	ctx := context.Background()
236	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
237		t.Fatalf("expected no error, got %v", err)
238	}
239
240	// We're probably not running as root, so preflight will fail
241	result, _ := service.RunPreflight(ctx)
242
243	// Either expect error or result indicating failure
244	if result != nil && result.ChecksPassed {
245		// If we're running as root, both err and checksPassed should be false
246		t.Error("expected preflight to fail when not running as root")
247	}
248}
249
250func TestInstallationService_RunPartition(t *testing.T) {
251	ctrl := gomock.NewController(t)
252	defer ctrl.Finish()
253
254	service := createTestService(ctrl)
255	defer func() {
256		if err := service.Close(); err != nil {
257			t.Fatalf("expected no error, got %v", err)
258		}
259	}()
260
261	ctx := context.Background()
262	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
263		t.Fatalf("expected no error, got %v", err)
264	}
265
266	cmd := commands.PartitionDiskCommand{
267		TargetDisk:     "/dev/sda",
268		RootSizeGB:     50,
269		BootSizeGB:     1,
270		EncryptionType: disk.EncryptionTypeNone,
271		FilesystemType: disk.FilesystemExt4,
272	}
273
274	result, err := service.RunPartition(ctx, cmd)
275
276	if err != nil {
277		t.Fatalf("expected no error, got %v", err)
278	}
279
280	if !result.Success {
281		t.Errorf("expected success, got error: %s", result.ErrorDetail)
282	}
283}
284
285func TestInstallationService_RunBaseInstall(t *testing.T) {
286	ctrl := gomock.NewController(t)
287	defer ctrl.Finish()
288
289	service := createTestService(ctrl)
290	defer func() {
291		if err := service.Close(); err != nil {
292			t.Fatalf("expected no error, got %v", err)
293		}
294	}()
295
296	ctx := context.Background()
297	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
298		t.Fatalf("expected no error, got %v", err)
299	}
300
301	cmd := commands.InstallBaseCommand{
302		TargetDisk:       "/dev/sda",
303		MountPoint:       "/mnt",
304		Packages:         []string{},
305		KernelVariant:    packages.KernelStable,
306		IncludeMicrocode: false,
307	}
308
309	result, err := service.RunBaseInstall(ctx, cmd)
310
311	if err != nil {
312		t.Fatalf("expected no error, got %v", err)
313	}
314
315	if !result.Success {
316		t.Error("expected success")
317	}
318}
319
320func TestInstallationService_RunConfigSystem(t *testing.T) {
321	ctrl := gomock.NewController(t)
322	defer ctrl.Finish()
323
324	service := createTestService(ctrl)
325	defer func() {
326		if err := service.Close(); err != nil {
327			t.Fatalf("expected no error, got %v", err)
328		}
329	}()
330
331	ctx := context.Background()
332	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
333		t.Fatalf("expected no error, got %v", err)
334	}
335
336	cmd := commands.ConfigureSystemCommand{
337		MountPoint:   "/mnt",
338		Hostname:     "myarch",
339		Timezone:     "UTC",
340		Locale:       "en_US.UTF-8",
341		Keymap:       "us",
342		Username:     "testuser",
343		UserShell:    "/bin/bash",
344		UserPassword: "testpass123",
345		RootPassword: "rootpass456",
346	}
347
348	result, err := service.RunConfigSystem(ctx, cmd)
349
350	if err != nil {
351		t.Fatalf("expected no error, got %v", err)
352	}
353
354	if !result.Success {
355		t.Error("expected success")
356	}
357}
358
359func TestInstallationService_RunBootloaderSetup(t *testing.T) {
360	ctrl := gomock.NewController(t)
361	defer ctrl.Finish()
362
363	service := createTestService(ctrl)
364	defer func() {
365		if err := service.Close(); err != nil {
366			t.Fatalf("expected no error, got %v", err)
367		}
368	}()
369
370	ctx := context.Background()
371	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
372		t.Fatalf("expected no error, got %v", err)
373	}
374
375	cmd := commands.InstallBootloaderCommand{
376		MountPoint:     "/mnt",
377		BootloaderType: bootloader.BootloaderTypeLimine,
378		TimeoutSeconds: 5,
379		Branding:       "ArchUp",
380		KernelVariant:  packages.KernelStable,
381		RootPartition:  "/dev/sda2",
382		EncryptionType: disk.EncryptionTypeNone,
383		EFIPartition:   "/dev/sda1",
384		TargetDisk:     "/dev/sda",
385	}
386
387	result, err := service.RunBootloaderSetup(ctx, cmd)
388
389	if err != nil {
390		t.Fatalf("expected no error, got %v", err)
391	}
392
393	if !result.Success {
394		t.Error("expected success")
395	}
396}
397
398func TestInstallationService_RunRepositorySetup(t *testing.T) {
399	ctrl := gomock.NewController(t)
400	defer ctrl.Finish()
401
402	service := createTestService(ctrl)
403	defer func() {
404		if err := service.Close(); err != nil {
405			t.Fatalf("expected no error, got %v", err)
406		}
407	}()
408
409	ctx := context.Background()
410	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
411		t.Fatalf("expected no error, got %v", err)
412	}
413
414	cmd := commands.SetupRepositoriesCommand{
415		MountPoint:      "/mnt",
416		EnableMultilib:  false,
417		AURHelper:       packages.AURHelperParu,
418		AdditionalRepos: []string{},
419	}
420
421	result, err := service.RunRepositorySetup(ctx, cmd)
422
423	if err != nil {
424		t.Fatalf("expected no error, got %v", err)
425	}
426
427	if !result.Success {
428		t.Error("expected success")
429	}
430}
431
432func TestInstallationService_RunPostInstall(t *testing.T) {
433	ctrl := gomock.NewController(t)
434	defer ctrl.Finish()
435
436	service := createTestService(ctrl)
437	defer func() {
438		if err := service.Close(); err != nil {
439			t.Fatalf("expected no error, got %v", err)
440		}
441	}()
442
443	ctx := context.Background()
444	if err := service.Start(ctx, "myarch", "testuser", "/dev/sda", "none"); err != nil {
445		t.Fatalf("expected no error, got %v", err)
446	}
447
448	cmd := commands.PostInstallCommand{
449		MountPoint:         "/mnt",
450		Username:           "testuser",
451		RunPostBootScripts: false,
452		PlymouthTheme:      "",
453	}
454
455	result, err := service.RunPostInstall(ctx, cmd)
456
457	if err != nil {
458		t.Fatalf("expected no error, got %v", err)
459	}
460
461	if !result.Success {
462		t.Error("expected success")
463	}
464}
465
466func TestInstallationService_CompleteNotStarted(t *testing.T) {
467	ctrl := gomock.NewController(t)
468	defer ctrl.Finish()
469
470	service := createTestService(ctrl)
471	defer func() {
472		if err := service.Close(); err != nil {
473			t.Fatalf("expected no error, got %v", err)
474		}
475	}()
476
477	ctx := context.Background()
478	// Don't start installation, try to complete it
479
480	err := service.Complete(ctx)
481
482	if err == nil {
483		t.Error("expected error when completing unstarted installation")
484	}
485}
486
487func TestInstallationService_SubscribeProgress(t *testing.T) {
488	ctrl := gomock.NewController(t)
489	defer ctrl.Finish()
490
491	service := createTestService(ctrl)
492	defer func() {
493		if err := service.Close(); err != nil {
494			t.Fatalf("expected no error, got %v", err)
495		}
496	}()
497
498	ch := service.SubscribeProgress()
499
500	if ch == nil {
501		t.Fatal("expected non-nil progress channel")
502	}
503}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 0

    ↑↓ moveenter openesc close