forked from baron/baron-sso
make drop 초기화 추가. 한맥그룹 기본값 추가
This commit is contained in:
81
Makefile
81
Makefile
@@ -11,6 +11,12 @@ COMPOSE_INFRA := compose.infra.yaml
|
||||
COMPOSE_ORY := compose.ory.yaml
|
||||
COMPOSE_APP := docker-compose.yaml
|
||||
AUTH_CONFIG_ENV := .generated/auth-config.env
|
||||
DEV_SERVICES ?= backend adminfront devfront orgfront userfront
|
||||
DEV_NETWORKS := baron_net ory-net hydranet kratosnet public_net
|
||||
INFRA_CONTAINERS := baron_postgres baron_clickhouse baron_redis baron_gateway
|
||||
ORY_CONTAINERS := ory_postgres ory_kratos ory_hydra ory_keto ory_oathkeeper ory_clickhouse ory_vector
|
||||
APP_CONTAINERS := baron_backend baron_adminfront baron_devfront baron_orgfront baron_userfront
|
||||
DROP_CONTAINERS := $(INFRA_CONTAINERS) $(ORY_CONTAINERS) $(APP_CONTAINERS) ory_stack_check
|
||||
|
||||
COMPOSE_CLI_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
@@ -18,6 +24,13 @@ COMPOSE_CLI_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
COMPOSE_CLI_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||
|
||||
COMPOSE_DROP_ENV_ARGS :=
|
||||
ifneq (,$(wildcard ./.env))
|
||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||
endif
|
||||
|
||||
.PHONY: build-auth-config validate-auth-config verify-auth-config up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory up-dev up-front-dev dev down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app
|
||||
|
||||
# --- 인증 설정 빌드/검증 ---
|
||||
build-auth-config:
|
||||
@echo "Building auth config..."
|
||||
@@ -34,38 +47,94 @@ verify-auth-config: validate-auth-config
|
||||
|
||||
# --- 기본 실행 ---
|
||||
# 주의: --remove-orphan 사용 금지 (다른 스택이 orphan으로 판단되어 종료될 수 있음)
|
||||
up-all: validate-auth-config
|
||||
up-all: ensure-networks validate-auth-config
|
||||
@echo "Starting ALL stacks (infra + ory + app)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) up -d
|
||||
|
||||
# --- 개별 스택 실행 ---
|
||||
up-infra:
|
||||
up-infra: ensure-networks
|
||||
@echo "Starting Infra stack (postgres/clickhouse/redis)..."
|
||||
docker compose -f $(COMPOSE_INFRA) up -d
|
||||
|
||||
up-ory: validate-auth-config
|
||||
up-ory: ensure-networks validate-auth-config
|
||||
@echo "Starting Ory stack (kratos/hydra/keto/oathkeeper)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d
|
||||
|
||||
up-app: validate-auth-config
|
||||
up-app: ensure-networks validate-auth-config
|
||||
@echo "Starting App stack (backend/userfront/adminfront/devfront)..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d
|
||||
|
||||
up-backend: validate-auth-config
|
||||
up-backend: ensure-networks validate-auth-config
|
||||
@echo "Starting Backend only..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up -d backend
|
||||
|
||||
up-dev: up-infra up-ory
|
||||
ensure-networks:
|
||||
@echo "Ensuring Docker networks..."
|
||||
@for network in $(DEV_NETWORKS); do \
|
||||
if ! docker network inspect "$$network" >/dev/null 2>&1; then \
|
||||
echo "Creating Docker network $$network..."; \
|
||||
docker network create "$$network"; \
|
||||
else \
|
||||
echo "Docker network $$network already exists."; \
|
||||
fi; \
|
||||
done
|
||||
|
||||
ensure-infra: ensure-networks
|
||||
@echo "Ensuring Infra stack..."
|
||||
@missing=0; \
|
||||
for container in $(INFRA_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
echo "Starting missing Infra stack containers in daemon mode..."; \
|
||||
docker compose -f $(COMPOSE_INFRA) up -d; \
|
||||
else \
|
||||
echo "Infra stack is already running."; \
|
||||
fi
|
||||
|
||||
ensure-ory: ensure-networks validate-auth-config
|
||||
@echo "Ensuring Ory stack..."
|
||||
@missing=0; \
|
||||
for container in $(ORY_CONTAINERS); do \
|
||||
if [ "$$(docker inspect -f '{{.State.Running}}' "$$container" 2>/dev/null)" != "true" ]; then \
|
||||
missing=1; \
|
||||
break; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ "$$missing" -eq 1 ]; then \
|
||||
echo "Starting missing Ory stack containers in daemon mode..."; \
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_ORY) up -d; \
|
||||
else \
|
||||
echo "Ory stack is already running."; \
|
||||
fi
|
||||
|
||||
up-dev: ensure-infra ensure-ory
|
||||
@echo "Dev stack is up (infra + ory)."
|
||||
|
||||
up-front-dev: up-infra up-ory up-backend
|
||||
@echo "Dev stack is up (infra + ory + backend)."
|
||||
|
||||
dev: up-dev
|
||||
@echo "Starting development app containers in foreground attach mode..."
|
||||
docker compose $(COMPOSE_CLI_ENV_ARGS) -f $(COMPOSE_APP) up $(DEV_SERVICES)
|
||||
|
||||
# --- 종료 (Down) ---
|
||||
down:
|
||||
@echo "Stopping ALL stacks (infra + ory + app)..."
|
||||
docker compose -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down
|
||||
|
||||
drop:
|
||||
@echo "Dropping Baron SSO local Docker stack containers, volumes, and local images..."
|
||||
-docker compose $(COMPOSE_DROP_ENV_ARGS) -f $(COMPOSE_INFRA) -f $(COMPOSE_ORY) -f $(COMPOSE_APP) down -v --rmi local
|
||||
@echo "Removing any remaining fixed-name Baron SSO containers..."
|
||||
@for container in $(DROP_CONTAINERS); do \
|
||||
docker rm -f "$$container" >/dev/null 2>&1 || true; \
|
||||
done
|
||||
@echo "Drop complete. External Docker networks are preserved."
|
||||
|
||||
down-app:
|
||||
@echo "Stopping App stack..."
|
||||
docker compose -f $(COMPOSE_APP) down
|
||||
|
||||
@@ -111,7 +111,7 @@ test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "org_chart_template.csv";
|
||||
a.download = "org_user_import_template.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -130,18 +130,18 @@ test2@example.com,이몽룡,삼안,선임,개발,팀원,기술본부,개발실,
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Upload size={14} />
|
||||
{t("ui.admin.org.import_btn", "조직도 임포트 (CSV/XLSX)")}
|
||||
{t("ui.admin.org.import_btn", "조직/사용자 통합 임포트")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.org.import_title", "조직도 일괄 등록")}
|
||||
{t("ui.admin.org.import_title", "조직/사용자 통합 일괄 등록")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.org.import_description",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.",
|
||||
"CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -159,7 +159,7 @@ scope = "Scope"
|
||||
|
||||
[msg.admin.org]
|
||||
hover_member_info = "Hover to see member details."
|
||||
import_description = "Upload a CSV file to bulk register the organization chart."
|
||||
import_description = "Upload a CSV or XLSX file to create or update organization tenants and users together, then map memberships."
|
||||
import_error = "An error occurred during organization chart import."
|
||||
import_success = "Organization chart imported successfully."
|
||||
|
||||
@@ -841,8 +841,8 @@ users = "Users"
|
||||
|
||||
[ui.admin.org]
|
||||
download_template = "Download Template"
|
||||
import_btn = "Import"
|
||||
import_title = "Bulk Organization Import"
|
||||
import_btn = "Org/User Import"
|
||||
import_title = "Bulk Org/User Import"
|
||||
start_import = "Start Import"
|
||||
|
||||
[ui.admin.overview]
|
||||
|
||||
@@ -159,7 +159,7 @@ scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
|
||||
|
||||
[msg.admin.org]
|
||||
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
|
||||
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직도를 일괄 등록합니다. (필수 컬럼: 이메일, 이름)"
|
||||
import_description = "CSV 또는 XLSX 파일을 업로드하여 조직 테넌트와 사용자를 함께 생성/업데이트하고 멤버십을 매핑합니다. (필수 컬럼: 이메일, 이름)"
|
||||
import_error = "조직도 임포트 중 오류가 발생했습니다."
|
||||
import_success = "조직도가 성공적으로 임포트되었습니다."
|
||||
|
||||
@@ -843,8 +843,8 @@ users = "사용자"
|
||||
|
||||
[ui.admin.org]
|
||||
download_template = "템플릿 다운로드"
|
||||
import_btn = "임포트"
|
||||
import_title = "조직도 대량 등록"
|
||||
import_btn = "조직/사용자 통합 임포트"
|
||||
import_title = "조직/사용자 통합 일괄 등록"
|
||||
start_import = "임포트 시작"
|
||||
|
||||
[ui.admin.overview]
|
||||
|
||||
@@ -114,6 +114,26 @@ test.describe("Tenants Management", () => {
|
||||
await expect(page).toHaveURL(/.*\/tenants$/, { timeout: 15000 });
|
||||
});
|
||||
|
||||
test("should clarify organization import creates org tenants and users together", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/tenants");
|
||||
await expect(page.locator("h2").last()).toContainText(
|
||||
/테넌트 목록|Tenants/i,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /임포트|Import/i })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("dialog")).toContainText(
|
||||
/조직 테넌트.*사용자|organization tenants.*users/i,
|
||||
);
|
||||
});
|
||||
|
||||
test("should show validation error on empty name", async ({ page }) => {
|
||||
await page.goto("/tenants/new");
|
||||
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -13,6 +15,8 @@ import (
|
||||
type InitialTenantConfig struct {
|
||||
Name string
|
||||
Slug string
|
||||
Type string
|
||||
ParentSlug string
|
||||
Description string
|
||||
Domains []string
|
||||
}
|
||||
@@ -20,11 +24,25 @@ type InitialTenantConfig struct {
|
||||
// Hardcoded for now, can be moved to config file or env later
|
||||
var defaultTenants = []InitialTenantConfig{
|
||||
{
|
||||
Name: "Hanmac Engineering",
|
||||
Name: "한맥가족",
|
||||
Slug: "hanmac-family",
|
||||
Type: domain.TenantTypeCompanyGroup,
|
||||
},
|
||||
{
|
||||
Name: "한맥기술",
|
||||
Slug: "hanmac",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentSlug: "hanmac-family",
|
||||
Description: "Primary Family Company",
|
||||
Domains: []string{"hanmaceng.co.kr", "hmac.kr"},
|
||||
},
|
||||
{
|
||||
Name: "삼안",
|
||||
Slug: "saman",
|
||||
Type: domain.TenantTypeCompany,
|
||||
ParentSlug: "hanmac-family",
|
||||
Domains: []string{"samaneng.com"},
|
||||
},
|
||||
}
|
||||
|
||||
func SeedTenants(db *gorm.DB) error {
|
||||
@@ -37,9 +55,65 @@ func SeedTenants(db *gorm.DB) error {
|
||||
ctx := context.Background()
|
||||
|
||||
for _, config := range defaultTenants {
|
||||
tenantType := config.Type
|
||||
if tenantType == "" {
|
||||
tenantType = domain.TenantTypeCompany
|
||||
}
|
||||
|
||||
var parentID *string
|
||||
if config.ParentSlug != "" {
|
||||
parent, err := repo.FindBySlug(ctx, config.ParentSlug)
|
||||
if err != nil || parent == nil {
|
||||
if err == nil {
|
||||
err = errors.New("parent tenant not found")
|
||||
}
|
||||
slog.Error("Failed to resolve parent tenant for seed", "slug", config.Slug, "parentSlug", config.ParentSlug, "error", err)
|
||||
return fmt.Errorf("resolve parent tenant %q for seed %q: %w", config.ParentSlug, config.Slug, err)
|
||||
}
|
||||
parentID = &parent.ID
|
||||
}
|
||||
|
||||
existing, err := repo.FindBySlug(ctx, config.Slug)
|
||||
if err == nil && existing != nil {
|
||||
slog.Info("[Bootstrap] Tenant already exists, checking domains...", "slug", config.Slug)
|
||||
changed := false
|
||||
if existing.Name != config.Name {
|
||||
existing.Name = config.Name
|
||||
changed = true
|
||||
}
|
||||
if existing.Type != tenantType {
|
||||
existing.Type = tenantType
|
||||
changed = true
|
||||
}
|
||||
if existing.Status != domain.TenantStatusActive {
|
||||
existing.Status = domain.TenantStatusActive
|
||||
changed = true
|
||||
}
|
||||
if config.ParentSlug != "" {
|
||||
if existing.ParentID == nil || *existing.ParentID != *parentID {
|
||||
existing.ParentID = parentID
|
||||
changed = true
|
||||
if err := outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: existing.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *parentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
}); err != nil {
|
||||
slog.Error("Failed to create outbox entry for seeded tenant hierarchy", "tenant", existing.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if existing.ParentID != nil {
|
||||
existing.ParentID = nil
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
if err := repo.Update(ctx, existing); err != nil {
|
||||
slog.Error("Failed to update seeded tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Optional: Check and add missing domains
|
||||
for _, d := range config.Domains {
|
||||
found := false
|
||||
@@ -60,7 +134,7 @@ func SeedTenants(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "")
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, tenantType, config.Description, config.Domains, parentID, "")
|
||||
if err != nil {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
|
||||
74
backend/internal/bootstrap/tenant_seed_test.go
Normal file
74
backend/internal/bootstrap/tenant_seed_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultTenantsSeedOrderAndHierarchy(t *testing.T) {
|
||||
expected := []struct {
|
||||
name string
|
||||
slug string
|
||||
tenantType string
|
||||
parentSlug string
|
||||
domains []string
|
||||
}{
|
||||
{
|
||||
name: "한맥가족",
|
||||
slug: "hanmac-family",
|
||||
tenantType: domain.TenantTypeCompanyGroup,
|
||||
},
|
||||
{
|
||||
name: "한맥기술",
|
||||
slug: "hanmac",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"hanmaceng.co.kr", "hmac.kr"},
|
||||
},
|
||||
{
|
||||
name: "삼안",
|
||||
slug: "saman",
|
||||
tenantType: domain.TenantTypeCompany,
|
||||
parentSlug: "hanmac-family",
|
||||
domains: []string{"samaneng.com"},
|
||||
},
|
||||
}
|
||||
|
||||
if len(defaultTenants) != len(expected) {
|
||||
t.Fatalf("expected %d default tenants, got %d", len(expected), len(defaultTenants))
|
||||
}
|
||||
|
||||
for i, want := range expected {
|
||||
got := defaultTenants[i]
|
||||
if got.Name != want.name {
|
||||
t.Fatalf("tenant[%d] name = %q, want %q", i, got.Name, want.name)
|
||||
}
|
||||
if got.Slug != want.slug {
|
||||
t.Fatalf("tenant[%d] slug = %q, want %q", i, got.Slug, want.slug)
|
||||
}
|
||||
if tenantType := stringField(t, got, "Type"); tenantType != want.tenantType {
|
||||
t.Fatalf("tenant[%d] type = %q, want %q", i, tenantType, want.tenantType)
|
||||
}
|
||||
if parentSlug := stringField(t, got, "ParentSlug"); parentSlug != want.parentSlug {
|
||||
t.Fatalf("tenant[%d] parent slug = %q, want %q", i, parentSlug, want.parentSlug)
|
||||
}
|
||||
if !reflect.DeepEqual(got.Domains, want.domains) {
|
||||
t.Fatalf("tenant[%d] domains = %#v, want %#v", i, got.Domains, want.domains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stringField(t *testing.T, target InitialTenantConfig, name string) string {
|
||||
t.Helper()
|
||||
|
||||
value := reflect.ValueOf(target)
|
||||
field := value.FieldByName(name)
|
||||
if !field.IsValid() {
|
||||
t.Fatalf("InitialTenantConfig.%s is required", name)
|
||||
}
|
||||
if field.Kind() != reflect.String {
|
||||
t.Fatalf("InitialTenantConfig.%s must be a string", name)
|
||||
}
|
||||
return field.String()
|
||||
}
|
||||
79
test/make_dev_targets_test.sh
Normal file
79
test/make_dev_targets_test.sh
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
dry_run_dev="$(
|
||||
make --dry-run --always-make -C "$repo_root" dev DEV_SERVICES="backend adminfront" 2>&1
|
||||
)"
|
||||
|
||||
if ! grep -q "Ensuring Infra stack" <<<"$dry_run_dev"; then
|
||||
echo "make dev must ensure the infra stack first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "Ensuring Ory stack" <<<"$dry_run_dev"; then
|
||||
echo "make dev must ensure the Ory stack first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_up_line="$(
|
||||
grep -E "docker compose .* -f docker-compose.yaml up .*backend.*adminfront" <<<"$dry_run_dev" | tail -1
|
||||
)"
|
||||
|
||||
if [[ -z "$app_up_line" ]]; then
|
||||
echo "make dev must run docker compose up for development app services." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q -- " -d" <<<"$app_up_line"; then
|
||||
echo "make dev must run app services in foreground attach mode without -d." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dry_run_up_dev="$(
|
||||
make --dry-run --always-make -C "$repo_root" up-dev 2>&1
|
||||
)"
|
||||
|
||||
if ! grep -q "Ensuring Infra stack" <<<"$dry_run_up_dev"; then
|
||||
echo "make up-dev must ensure the infra stack." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "Ensuring Ory stack" <<<"$dry_run_up_dev"; then
|
||||
echo "make up-dev must ensure the Ory stack." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dry_run_up_all="$(
|
||||
make --dry-run --always-make -C "$repo_root" up-all 2>&1
|
||||
)"
|
||||
|
||||
if ! grep -q "Ensuring Docker networks" <<<"$dry_run_up_all"; then
|
||||
echo "make up-all must ensure external Docker networks before compose up." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'docker network create "$network"' <<<"$dry_run_up_all"; then
|
||||
echo "make up-all must create missing external Docker networks." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dry_run_drop="$(
|
||||
make --dry-run --always-make -C "$repo_root" drop 2>&1
|
||||
)"
|
||||
|
||||
if ! grep -q "Dropping Baron SSO local Docker stack" <<<"$dry_run_drop"; then
|
||||
echo "make drop must announce that it is dropping the local stack." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q -- "down -v --rmi local" <<<"$dry_run_drop"; then
|
||||
echo "make drop must remove containers, volumes, and local compose images." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "docker rm -f" <<<"$dry_run_drop"; then
|
||||
echo "make drop must force-remove known fixed-name stack containers." >&2
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user