diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index c827538c..aa13b880 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -18,7 +18,7 @@ jobs: cicd-docker: name: Cargo and npm build #runs-on: ubuntu-latest - runs-on: self-hosted + runs-on: [self-hosted, linux] env: SQLX_OFFLINE: true steps: @@ -156,7 +156,7 @@ jobs: cicd-linux-docker: name: CICD Docker #runs-on: ubuntu-latest - runs-on: self-hosted + runs-on: [self-hosted, linux] needs: cicd-docker steps: - name: Checkout sources @@ -195,7 +195,7 @@ jobs: stackerdb-docker: name: StackerDB Docker #runs-on: ubuntu-latest - runs-on: self-hosted + runs-on: [self-hosted, linux] needs: cicd-docker steps: - name: Checkout sources diff --git a/.sqlx/query-184840fbb1e0b2fd96590d10ac17fdfa93456f28c3a62c4a1ac78bcf69d58b09.json b/.sqlx/query-184840fbb1e0b2fd96590d10ac17fdfa93456f28c3a62c4a1ac78bcf69d58b09.json deleted file mode 100644 index 7a55df12..00000000 --- a/.sqlx/query-184840fbb1e0b2fd96590d10ac17fdfa93456f28c3a62c4a1ac78bcf69d58b09.json +++ /dev/null @@ -1,146 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT \n t.id,\n t.creator_user_id,\n t.creator_name,\n t.name,\n t.slug,\n t.short_description,\n t.long_description,\n c.name AS \"category_code?\",\n t.product_id,\n t.tags,\n t.tech_stack,\n t.status,\n t.is_configurable,\n t.view_count,\n t.deploy_count,\n t.required_plan_name,\n t.price,\n t.billing_cycle,\n t.currency,\n t.created_at,\n t.updated_at,\n t.approved_at\n FROM stack_template t\n LEFT JOIN stack_category c ON t.category_id = c.id\n WHERE t.status IN ('submitted', 'approved')\n ORDER BY \n CASE t.status\n WHEN 'submitted' THEN 0\n WHEN 'approved' THEN 1\n END,\n t.created_at ASC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "creator_user_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "creator_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "short_description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "long_description", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "category_code?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "product_id", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "tags", - "type_info": "Jsonb" - }, - { - "ordinal": 10, - "name": "tech_stack", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_configurable", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "view_count", - "type_info": "Int4" - }, - { - "ordinal": 14, - "name": "deploy_count", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "required_plan_name", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "price", - "type_info": "Float8" - }, - { - "ordinal": 17, - "name": "billing_cycle", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 20, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 21, - "name": "approved_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - false, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "184840fbb1e0b2fd96590d10ac17fdfa93456f28c3a62c4a1ac78bcf69d58b09" -} diff --git a/.sqlx/query-1cabd2f674da323da9e0da724d3bcfe5f968b31500e8c8cf97fe16814bc04164.json b/.sqlx/query-1cabd2f674da323da9e0da724d3bcfe5f968b31500e8c8cf97fe16814bc04164.json new file mode 100644 index 00000000..eb3a84f0 --- /dev/null +++ b/.sqlx/query-1cabd2f674da323da9e0da724d3bcfe5f968b31500e8c8cf97fe16814bc04164.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO casbin_rule ( ptype, v0, v1, v2, v3, v4, v5 )\n VALUES ( $1, $2, $3, $4, $5, $6, $7 )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1cabd2f674da323da9e0da724d3bcfe5f968b31500e8c8cf97fe16814bc04164" +} diff --git a/.sqlx/query-1f299262f01a2c9d2ee94079a12766573c91b2775a086c65bc9a5fdc91300bb0.json b/.sqlx/query-1f299262f01a2c9d2ee94079a12766573c91b2775a086c65bc9a5fdc91300bb0.json new file mode 100644 index 00000000..1ea12e39 --- /dev/null +++ b/.sqlx/query-1f299262f01a2c9d2ee94079a12766573c91b2775a086c65bc9a5fdc91300bb0.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v3 is NULL OR v3 = COALESCE($2,v3)) AND\n (v4 is NULL OR v4 = COALESCE($3,v4)) AND\n (v5 is NULL OR v5 = COALESCE($4,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1f299262f01a2c9d2ee94079a12766573c91b2775a086c65bc9a5fdc91300bb0" +} diff --git a/.sqlx/query-24876462291b90324dfe3682e9f36247a328db780a48da47c9402e1d3ebd80c9.json b/.sqlx/query-24876462291b90324dfe3682e9f36247a328db780a48da47c9402e1d3ebd80c9.json new file mode 100644 index 00000000..8046c5db --- /dev/null +++ b/.sqlx/query-24876462291b90324dfe3682e9f36247a328db780a48da47c9402e1d3ebd80c9.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "24876462291b90324dfe3682e9f36247a328db780a48da47c9402e1d3ebd80c9" +} diff --git a/.sqlx/query-27d5c5d688f0ee38fb6db48ef062b31a3f661b0d7351d648f24f277467d5ca2d.json b/.sqlx/query-27d5c5d688f0ee38fb6db48ef062b31a3f661b0d7351d648f24f277467d5ca2d.json deleted file mode 100644 index 9595775f..00000000 --- a/.sqlx/query-27d5c5d688f0ee38fb6db48ef062b31a3f661b0d7351d648f24f277467d5ca2d.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT \n t.id,\n t.creator_user_id,\n t.creator_name,\n t.name,\n t.slug,\n t.short_description,\n t.long_description,\n c.name AS \"category_code?\",\n t.product_id,\n t.tags,\n t.tech_stack,\n t.status,\n t.is_configurable,\n t.view_count,\n t.deploy_count,\n t.created_at,\n t.updated_at,\n t.approved_at,\n t.required_plan_name,\n t.price,\n t.billing_cycle,\n t.currency\n FROM stack_template t\n LEFT JOIN stack_category c ON t.category_id = c.id\n WHERE t.id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "creator_user_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "creator_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "short_description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "long_description", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "category_code?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "product_id", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "tags", - "type_info": "Jsonb" - }, - { - "ordinal": 10, - "name": "tech_stack", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_configurable", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "view_count", - "type_info": "Int4" - }, - { - "ordinal": 14, - "name": "deploy_count", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 16, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 17, - "name": "approved_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 18, - "name": "required_plan_name", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "price", - "type_info": "Float8" - }, - { - "ordinal": 20, - "name": "billing_cycle", - "type_info": "Varchar" - }, - { - "ordinal": 21, - "name": "currency", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - false, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "27d5c5d688f0ee38fb6db48ef062b31a3f661b0d7351d648f24f277467d5ca2d" -} diff --git a/.sqlx/query-2872b56bbc5bed96b1a303bf9cf44610fb79a1b9330730c65953f0c1b88c2a53.json b/.sqlx/query-2872b56bbc5bed96b1a303bf9cf44610fb79a1b9330730c65953f0c1b88c2a53.json new file mode 100644 index 00000000..e246e53b --- /dev/null +++ b/.sqlx/query-2872b56bbc5bed96b1a303bf9cf44610fb79a1b9330730c65953f0c1b88c2a53.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n v0 = $2 AND\n v1 = $3 AND\n v2 = $4 AND\n v3 = $5 AND\n v4 = $6 AND\n v5 = $7", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2872b56bbc5bed96b1a303bf9cf44610fb79a1b9330730c65953f0c1b88c2a53" +} diff --git a/.sqlx/query-3ae7e28de7cb8896086c186dbc0e78f2a23eff67925322bdd3646d063d710584.json b/.sqlx/query-3ae7e28de7cb8896086c186dbc0e78f2a23eff67925322bdd3646d063d710584.json new file mode 100644 index 00000000..6f824756 --- /dev/null +++ b/.sqlx/query-3ae7e28de7cb8896086c186dbc0e78f2a23eff67925322bdd3646d063d710584.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, ptype, v0, v1, v2, v3, v4, v5 FROM casbin_rule", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "ptype", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "v0", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "v1", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "v2", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "v3", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "v4", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "v5", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "3ae7e28de7cb8896086c186dbc0e78f2a23eff67925322bdd3646d063d710584" +} diff --git a/.sqlx/query-438ee38e669be96e562d09d3bc5806b4c78b7aa2a9609c4eccb941c7dff7b107.json b/.sqlx/query-438ee38e669be96e562d09d3bc5806b4c78b7aa2a9609c4eccb941c7dff7b107.json new file mode 100644 index 00000000..75c6da35 --- /dev/null +++ b/.sqlx/query-438ee38e669be96e562d09d3bc5806b4c78b7aa2a9609c4eccb941c7dff7b107.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "CREATE TABLE IF NOT EXISTS casbin_rule (\n id SERIAL PRIMARY KEY,\n ptype VARCHAR NOT NULL,\n v0 VARCHAR NOT NULL,\n v1 VARCHAR NOT NULL,\n v2 VARCHAR NOT NULL,\n v3 VARCHAR NOT NULL,\n v4 VARCHAR NOT NULL,\n v5 VARCHAR NOT NULL,\n CONSTRAINT unique_key_sqlx_adapter UNIQUE(ptype, v0, v1, v2, v3, v4, v5)\n );\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "438ee38e669be96e562d09d3bc5806b4c78b7aa2a9609c4eccb941c7dff7b107" +} diff --git a/.sqlx/query-463efe189d11f943d76f806de8471446f52bd00706421b02b4dacc0140c574c1.json b/.sqlx/query-463efe189d11f943d76f806de8471446f52bd00706421b02b4dacc0140c574c1.json deleted file mode 100644 index f3cf179e..00000000 --- a/.sqlx/query-463efe189d11f943d76f806de8471446f52bd00706421b02b4dacc0140c574c1.json +++ /dev/null @@ -1,159 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO stack_template (\n creator_user_id, creator_name, name, slug,\n short_description, long_description, category_id,\n tags, tech_stack, status, price, billing_cycle, currency\n ) VALUES ($1,$2,$3,$4,$5,$6,(SELECT id FROM stack_category WHERE name = $7),$8,$9,'draft',$10,$11,$12)\n RETURNING \n id,\n creator_user_id,\n creator_name,\n name,\n slug,\n short_description,\n long_description,\n (SELECT name FROM stack_category WHERE id = category_id) AS \"category_code?\",\n product_id,\n tags,\n tech_stack,\n status,\n is_configurable,\n view_count,\n deploy_count,\n required_plan_name,\n price,\n billing_cycle,\n currency,\n created_at,\n updated_at,\n approved_at\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "creator_user_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "creator_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "short_description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "long_description", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "category_code?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "product_id", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "tags", - "type_info": "Jsonb" - }, - { - "ordinal": 10, - "name": "tech_stack", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_configurable", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "view_count", - "type_info": "Int4" - }, - { - "ordinal": 14, - "name": "deploy_count", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "required_plan_name", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "price", - "type_info": "Float8" - }, - { - "ordinal": 17, - "name": "billing_cycle", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 20, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 21, - "name": "approved_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Varchar", - "Text", - "Text", - "Text", - "Jsonb", - "Jsonb", - "Float8", - "Varchar", - "Varchar" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - null, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "463efe189d11f943d76f806de8471446f52bd00706421b02b4dacc0140c574c1" -} diff --git a/.sqlx/query-4acfe0086a593b08177791bb3b47cb75a999041a3eb6a8f8177bebfa3c30d56f.json b/.sqlx/query-4acfe0086a593b08177791bb3b47cb75a999041a3eb6a8f8177bebfa3c30d56f.json new file mode 100644 index 00000000..ce229dc4 --- /dev/null +++ b/.sqlx/query-4acfe0086a593b08177791bb3b47cb75a999041a3eb6a8f8177bebfa3c30d56f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v4 is NULL OR v4 = COALESCE($2,v4)) AND\n (v5 is NULL OR v5 = COALESCE($3,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4acfe0086a593b08177791bb3b47cb75a999041a3eb6a8f8177bebfa3c30d56f" +} diff --git a/.sqlx/query-4e7b82d256f7298564f46af6a45b89853785c32a5f83cb0b25609329c760428a.json b/.sqlx/query-4e7b82d256f7298564f46af6a45b89853785c32a5f83cb0b25609329c760428a.json new file mode 100644 index 00000000..4c4c1df2 --- /dev/null +++ b/.sqlx/query-4e7b82d256f7298564f46af6a45b89853785c32a5f83cb0b25609329c760428a.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v1 is NULL OR v1 = COALESCE($2,v1)) AND\n (v2 is NULL OR v2 = COALESCE($3,v2)) AND\n (v3 is NULL OR v3 = COALESCE($4,v3)) AND\n (v4 is NULL OR v4 = COALESCE($5,v4)) AND\n (v5 is NULL OR v5 = COALESCE($6,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e7b82d256f7298564f46af6a45b89853785c32a5f83cb0b25609329c760428a" +} diff --git a/.sqlx/query-530d3f59ba6d986d3354242ff25faae78671d69c8935d2a2d57c0f9d1e91e832.json b/.sqlx/query-530d3f59ba6d986d3354242ff25faae78671d69c8935d2a2d57c0f9d1e91e832.json new file mode 100644 index 00000000..d0df28a9 --- /dev/null +++ b/.sqlx/query-530d3f59ba6d986d3354242ff25faae78671d69c8935d2a2d57c0f9d1e91e832.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, ptype, v0, v1, v2, v3, v4, v5 from casbin_rule WHERE (\n ptype LIKE 'g%' AND v0 LIKE $1 AND v1 LIKE $2 AND v2 LIKE $3 AND v3 LIKE $4 AND v4 LIKE $5 AND v5 LIKE $6 )\n OR (\n ptype LIKE 'p%' AND v0 LIKE $7 AND v1 LIKE $8 AND v2 LIKE $9 AND v3 LIKE $10 AND v4 LIKE $11 AND v5 LIKE $12 );\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "ptype", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "v0", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "v1", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "v2", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "v3", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "v4", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "v5", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "530d3f59ba6d986d3354242ff25faae78671d69c8935d2a2d57c0f9d1e91e832" +} diff --git a/.sqlx/query-58451f6a71d026c5d868c22d58513e193b2b157f0c679c54791276fed9d638aa.json b/.sqlx/query-58451f6a71d026c5d868c22d58513e193b2b157f0c679c54791276fed9d638aa.json deleted file mode 100644 index 6064e69a..00000000 --- a/.sqlx/query-58451f6a71d026c5d868c22d58513e193b2b157f0c679c54791276fed9d638aa.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT \n t.id,\n t.creator_user_id,\n t.creator_name,\n t.name,\n t.slug,\n t.short_description,\n t.long_description,\n c.name AS \"category_code?\",\n t.product_id,\n t.tags,\n t.tech_stack,\n t.status,\n t.is_configurable,\n t.view_count,\n t.deploy_count,\n t.required_plan_name,\n t.price,\n t.billing_cycle,\n t.currency,\n t.created_at,\n t.updated_at,\n t.approved_at\n FROM stack_template t\n LEFT JOIN stack_category c ON t.category_id = c.id\n WHERE t.slug = $1 AND t.status = 'approved'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "creator_user_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "creator_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "short_description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "long_description", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "category_code?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "product_id", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "tags", - "type_info": "Jsonb" - }, - { - "ordinal": 10, - "name": "tech_stack", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_configurable", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "view_count", - "type_info": "Int4" - }, - { - "ordinal": 14, - "name": "deploy_count", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "required_plan_name", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "price", - "type_info": "Float8" - }, - { - "ordinal": 17, - "name": "billing_cycle", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 20, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 21, - "name": "approved_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - false, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "58451f6a71d026c5d868c22d58513e193b2b157f0c679c54791276fed9d638aa" -} diff --git a/.sqlx/query-91c6d630cb34f4d85a8d9ecdf7a1438ccb73ce433d52a4243d9ebc0b98124310.json b/.sqlx/query-91c6d630cb34f4d85a8d9ecdf7a1438ccb73ce433d52a4243d9ebc0b98124310.json deleted file mode 100644 index 1a20b94d..00000000 --- a/.sqlx/query-91c6d630cb34f4d85a8d9ecdf7a1438ccb73ce433d52a4243d9ebc0b98124310.json +++ /dev/null @@ -1,148 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT \n t.id,\n t.creator_user_id,\n t.creator_name,\n t.name,\n t.slug,\n t.short_description,\n t.long_description,\n c.name AS \"category_code?\",\n t.product_id,\n t.tags,\n t.tech_stack,\n t.status,\n t.is_configurable,\n t.view_count,\n t.deploy_count,\n t.required_plan_name,\n t.price,\n t.billing_cycle,\n t.currency,\n t.created_at,\n t.updated_at,\n t.approved_at\n FROM stack_template t\n LEFT JOIN stack_category c ON t.category_id = c.id\n WHERE t.creator_user_id = $1\n ORDER BY t.created_at DESC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "creator_user_id", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "creator_name", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "slug", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "short_description", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "long_description", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "category_code?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "product_id", - "type_info": "Int4" - }, - { - "ordinal": 9, - "name": "tags", - "type_info": "Jsonb" - }, - { - "ordinal": 10, - "name": "tech_stack", - "type_info": "Jsonb" - }, - { - "ordinal": 11, - "name": "status", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "is_configurable", - "type_info": "Bool" - }, - { - "ordinal": 13, - "name": "view_count", - "type_info": "Int4" - }, - { - "ordinal": 14, - "name": "deploy_count", - "type_info": "Int4" - }, - { - "ordinal": 15, - "name": "required_plan_name", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "price", - "type_info": "Float8" - }, - { - "ordinal": 17, - "name": "billing_cycle", - "type_info": "Varchar" - }, - { - "ordinal": 18, - "name": "currency", - "type_info": "Varchar" - }, - { - "ordinal": 19, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 20, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 21, - "name": "approved_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true, - false, - false, - true, - true, - false, - true, - true, - true, - false, - true, - true, - true, - true, - true, - true, - true, - true, - true, - true - ] - }, - "hash": "91c6d630cb34f4d85a8d9ecdf7a1438ccb73ce433d52a4243d9ebc0b98124310" -} diff --git a/.sqlx/query-f130c22d14ee2a99b9220ac1a45226ba97993ede9988a4c57d58bd066500a119.json b/.sqlx/query-f130c22d14ee2a99b9220ac1a45226ba97993ede9988a4c57d58bd066500a119.json new file mode 100644 index 00000000..ef54cdb3 --- /dev/null +++ b/.sqlx/query-f130c22d14ee2a99b9220ac1a45226ba97993ede9988a4c57d58bd066500a119.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v0 is NULL OR v0 = COALESCE($2,v0)) AND\n (v1 is NULL OR v1 = COALESCE($3,v1)) AND\n (v2 is NULL OR v2 = COALESCE($4,v2)) AND\n (v3 is NULL OR v3 = COALESCE($5,v3)) AND\n (v4 is NULL OR v4 = COALESCE($6,v4)) AND\n (v5 is NULL OR v5 = COALESCE($7,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f130c22d14ee2a99b9220ac1a45226ba97993ede9988a4c57d58bd066500a119" +} diff --git a/.sqlx/query-f8611a862ed1d3b982e8aa5ccab21e00c42a3fad8082cf15c2af88cd8388f41b.json b/.sqlx/query-f8611a862ed1d3b982e8aa5ccab21e00c42a3fad8082cf15c2af88cd8388f41b.json new file mode 100644 index 00000000..0daaa8a8 --- /dev/null +++ b/.sqlx/query-f8611a862ed1d3b982e8aa5ccab21e00c42a3fad8082cf15c2af88cd8388f41b.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v2 is NULL OR v2 = COALESCE($2,v2)) AND\n (v3 is NULL OR v3 = COALESCE($3,v3)) AND\n (v4 is NULL OR v4 = COALESCE($4,v4)) AND\n (v5 is NULL OR v5 = COALESCE($5,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f8611a862ed1d3b982e8aa5ccab21e00c42a3fad8082cf15c2af88cd8388f41b" +} diff --git a/.sqlx/query-fa51ae7af271fc17c848694fbf1b37d46c5a2f4202e1b8dce1f66a65069beb0b.json b/.sqlx/query-fa51ae7af271fc17c848694fbf1b37d46c5a2f4202e1b8dce1f66a65069beb0b.json new file mode 100644 index 00000000..4a5f7e80 --- /dev/null +++ b/.sqlx/query-fa51ae7af271fc17c848694fbf1b37d46c5a2f4202e1b8dce1f66a65069beb0b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM casbin_rule WHERE\n ptype = $1 AND\n (v5 is NULL OR v5 = COALESCE($2,v5))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "fa51ae7af271fc17c848694fbf1b37d46c5a2f4202e1b8dce1f66a65069beb0b" +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 300c9673..40b2e843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Fixed — Casbin ACL for marketplace compose access +- Added Casbin policy granting `group_admin` role GET access to `/admin/project/:id/compose`. +- This allows the User Service OAuth client (which authenticates as `root` → `group_admin`) to fetch compose definitions for marketplace templates. +- Migration: `20260325140000_casbin_admin_compose_group_admin.up.sql` + ### Added — Agent Audit Ingest Endpoint and Query API - New database migration `20260321000000_agent_audit_log` creating the `agent_audit_log` table diff --git a/Cargo.lock b/Cargo.lock index ba52eb77..b01ea4c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1210,7 +1210,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -2031,7 +2031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2121,6 +2121,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -2783,7 +2794,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -3108,7 +3119,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi 0.5.2", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3546,7 +3557,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4820,7 +4831,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5717,6 +5728,7 @@ dependencies = [ "dialoguer", "docker-compose-types", "dotenvy", + "flate2", "futures", "futures-lite 2.6.1", "futures-util", @@ -5742,6 +5754,7 @@ dependencies = [ "sqlx", "sqlx-adapter", "ssh-key", + "tar", "tempfile", "tera", "thiserror 1.0.69", @@ -5864,6 +5877,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tcp-stream" version = "0.28.0" @@ -5886,7 +5910,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.3", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5917,7 +5941,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6646,7 +6670,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7068,6 +7092,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.3", +] + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/Cargo.toml b/Cargo.toml index ace1b9d4..6d702d24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ actix-cors = "0.6.4" tracing-actix-web = "0.7.7" regex = "1.10.2" rand = "0.8.5" +tempfile = "3" +flate2 = "1.0" +tar = "0.4" ssh-key = { version = "0.6", features = ["ed25519", "rand_core"] } russh = "0.58" futures-util = "0.3.29" @@ -101,5 +104,4 @@ glob = "0.3" wiremock = "0.5.22" assert_cmd = "2.0" predicates = "3.0" -tempfile = "3" mockito = "1" diff --git a/TODO.md b/TODO.md index 6a47c8cc..e0d702ab 100644 --- a/TODO.md +++ b/TODO.md @@ -1100,3 +1100,47 @@ Deployment proceeds (user owns product) - [try.direct.user.service/TODO.md](try.direct.user.service/TODO.md) - User Service implementation - [try.direct.tools/TODO.md](try.direct.tools/TODO.md) - Shared utilities - [blog/TODO.md](blog/TODO.md) - Frontend marketplace UI + + +## Marketplace Template Hardened Images — Docker Hub API Enhancement + +**Status:** Static analysis implemented. API-based verification pending. + +### What is implemented (static analysis in `security_validator.rs`) +- `:latest` / untagged image detection +- Non-root `user:` directive detection +- `image@sha256:` digest pinning detection +- Known hardened sources: `cgr.dev/`, `gcr.io/distroless/`, `bitnami/`, `rapidfort/`, `registry1.dso.mil/` +- Docker Official Images (no-namespace single-word images like `nginx:1.25`) +- `hardened_images` auto-set in `verifications` JSONB when security scan passes +- Priority sort boost: hardened templates float to top of all `list_approved` sort orders + +### TODO: Docker Hub API integration + +To verify `is_official` and `is_verified_publisher` status for each image: + +1. **Extend `DockerHubConnector` trait** (`src/connectors/docker_hub/connector.rs`): + ```rust + async fn get_repository_info(&self, namespace: &str, name: &str) -> Result; + ``` + Where `RepositoryInfo` adds: + ```rust + pub is_official: bool, + pub is_verified_publisher: bool, + pub pull_count: u64, + ``` + +2. **Make `security_scan_handler` call Docker Hub API** for each image found in the stack: + - Parse image names from `services.*.image` + - For each: call `docker_hub.get_repository_info(namespace, name)` + - Aggregate: set `hardened_images=true` if all images are official/verified-publisher OR from static hardened sources + - Currently the validator is sync — need to either make it async or do the Docker Hub check separately in the handler (preferred) + +3. **Rate limiting**: Docker Hub API allows 100 requests/hour for unauthenticated, 200/hour for authenticated. Cache results in Redis (`docker_hub:repo:{namespace}/{name}`) with 24h TTL. + +4. **Trivy/Grype integration** (separate from hardened_images): + - Run `trivy image --format json {image}` in a subprocess for each scanned stack + - Parse CVE list, severity counts + - Store results in `stack_template_review.security_checklist["cve_scan"]` + - Auto-set `verifications.vulnerability_scanned = true` when scan passes (no HIGH/CRITICAL CVEs) + diff --git a/migrations/20260330100000_add_verifications_to_stack_template.down.sql b/migrations/20260330100000_add_verifications_to_stack_template.down.sql new file mode 100644 index 00000000..9301e3ae --- /dev/null +++ b/migrations/20260330100000_add_verifications_to_stack_template.down.sql @@ -0,0 +1 @@ +ALTER TABLE stack_template DROP COLUMN IF EXISTS verifications; diff --git a/migrations/20260330100000_add_verifications_to_stack_template.up.sql b/migrations/20260330100000_add_verifications_to_stack_template.up.sql new file mode 100644 index 00000000..3c20dafe --- /dev/null +++ b/migrations/20260330100000_add_verifications_to_stack_template.up.sql @@ -0,0 +1,5 @@ +-- Add verifications JSONB column to stack_template. +-- This stores admin-configurable verification flags for marketplace templates. +-- Example: {"security_reviewed": true, "https_ready": false, "open_source": true} +ALTER TABLE stack_template + ADD COLUMN IF NOT EXISTS verifications JSONB NOT NULL DEFAULT '{}'::jsonb; diff --git a/migrations/20260330110000_casbin_admin_verifications_rule.down.sql b/migrations/20260330110000_casbin_admin_verifications_rule.down.sql new file mode 100644 index 00000000..545ca911 --- /dev/null +++ b/migrations/20260330110000_casbin_admin_verifications_rule.down.sql @@ -0,0 +1,4 @@ +DELETE FROM public.casbin_rule WHERE v1 IN ( + '/api/admin/templates/:id/verifications', + '/stacker/admin/templates/:id/verifications' +) AND v2 = 'PATCH'; diff --git a/migrations/20260330110000_casbin_admin_verifications_rule.up.sql b/migrations/20260330110000_casbin_admin_verifications_rule.up.sql new file mode 100644 index 00000000..400011ac --- /dev/null +++ b/migrations/20260330110000_casbin_admin_verifications_rule.up.sql @@ -0,0 +1,16 @@ +-- Add Casbin rules for admin template verifications endpoint +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/api/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/api/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'admin_service', '/stacker/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/stacker/admin/templates/:id/verifications', 'PATCH', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql b/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql new file mode 100644 index 00000000..f502ea0e --- /dev/null +++ b/migrations/20260331132000_casbin_dockerhub_events_rule.down.sql @@ -0,0 +1,2 @@ +DELETE FROM public.casbin_rule +WHERE ptype = 'p' AND v1 = '/dockerhub/events' AND v2 = 'POST'; diff --git a/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql b/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql new file mode 100644 index 00000000..3b0f0666 --- /dev/null +++ b/migrations/20260331132000_casbin_dockerhub_events_rule.up.sql @@ -0,0 +1,8 @@ +-- Allow authenticated users to post DockerHub autocomplete analytics events +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_user', '/dockerhub/events', 'POST', '', '', '') +ON CONFLICT DO NOTHING; + +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) +VALUES ('p', 'group_admin', '/dockerhub/events', 'POST', '', '', '') +ON CONFLICT DO NOTHING; diff --git a/src/cli/config_parser.rs b/src/cli/config_parser.rs index bb01f828..f8462c18 100644 --- a/src/cli/config_parser.rs +++ b/src/cli/config_parser.rs @@ -161,6 +161,7 @@ pub enum CloudProvider { Aws, Linode, Vultr, + Contabo, } /// Cloud orchestration mode. @@ -180,6 +181,7 @@ impl fmt::Display for CloudProvider { Self::Aws => write!(f, "aws"), Self::Linode => write!(f, "linode"), Self::Vultr => write!(f, "vultr"), + Self::Contabo => write!(f, "contabo"), } } } diff --git a/src/cli/install_runner.rs b/src/cli/install_runner.rs index 7d684d3c..7aca8174 100644 --- a/src/cli/install_runner.rs +++ b/src/cli/install_runner.rs @@ -152,6 +152,20 @@ pub fn strategy_for(target: &DeployTarget) -> Box { // LocalDeploy — docker compose up/down // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +/// Detect which compose invocation is available on this host. +/// +/// Returns `("docker", vec!["compose"])` when the Docker Compose plugin is +/// installed (`docker compose version` exits 0), or `("docker-compose", vec![])` +/// when only the standalone tool is available. +fn resolve_compose_cmd(executor: &dyn CommandExecutor) -> (&'static str, Vec<&'static str>) { + if let Ok(out) = executor.execute("docker", &["compose", "version"]) { + if out.success() { + return ("docker", vec!["compose"]); + } + } + ("docker-compose", vec![]) +} + pub struct LocalDeploy; impl DeployStrategy for LocalDeploy { @@ -167,9 +181,25 @@ impl DeployStrategy for LocalDeploy { context: &DeployContext, executor: &dyn CommandExecutor, ) -> Result { + // In dry-run mode, artifacts have already been generated. + // Skip calling docker compose — it may not be available in all environments, + // and "dry run" means "preview, don't execute". + if context.dry_run { + return Ok(DeployResult { + target: DeployTarget::Local, + message: "Local deployment previewed successfully (dry-run)".to_string(), + server_ip: None, + deployment_id: None, + project_id: None, + server_name: None, + }); + } + let compose_path = context.compose_path.to_string_lossy().to_string(); - let mut args: Vec = vec!["compose".into()]; + let (cmd, base_args) = resolve_compose_cmd(executor); + let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + if let Some(ref env_file) = config.env_file { let env_file_path = if env_file.is_absolute() { env_file.clone() @@ -179,19 +209,14 @@ impl DeployStrategy for LocalDeploy { args.push("--env-file".into()); args.push(env_file_path.to_string_lossy().to_string()); } - args.push("-f".into()); + args.push("--file".into()); args.push(compose_path.clone()); - - if context.dry_run { - args.push("config".into()); - } else { - args.push("up".into()); - args.push("-d".into()); - args.push("--build".into()); - } + args.push("up".into()); + args.push("-d".into()); + args.push("--build".into()); let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let output = executor.execute("docker", &args_refs)?; + let output = executor.execute(cmd, &args_refs)?; if !output.stdout.trim().is_empty() { println!("{}", output.stdout); @@ -207,10 +232,9 @@ impl DeployStrategy for LocalDeploy { }); } - let action = if context.dry_run { "validated" } else { "started" }; Ok(DeployResult { target: DeployTarget::Local, - message: format!("Local deployment {} successfully", action), + message: "Local deployment started successfully".to_string(), server_ip: None, deployment_id: None, project_id: None, @@ -225,7 +249,10 @@ impl DeployStrategy for LocalDeploy { executor: &dyn CommandExecutor, ) -> Result<(), CliError> { let compose_path = context.compose_path.to_string_lossy().to_string(); - let mut args: Vec = vec!["compose".into()]; + + let (cmd, base_args) = resolve_compose_cmd(executor); + let mut args: Vec = base_args.iter().map(|s| s.to_string()).collect(); + if let Some(ref env_file) = config.env_file { let env_file_path = if env_file.is_absolute() { env_file.clone() @@ -235,12 +262,12 @@ impl DeployStrategy for LocalDeploy { args.push("--env-file".into()); args.push(env_file_path.to_string_lossy().to_string()); } - args.push("-f".into()); + args.push("--file".into()); args.push(compose_path); args.push("down".into()); args.push("--volumes".into()); let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); - let output = executor.execute("docker", &args_refs)?; + let output = executor.execute(cmd, &args_refs)?; if !output.success() { return Err(CliError::DeployFailed { @@ -798,6 +825,7 @@ pub fn provider_code_for_remote(config_provider: &str) -> &str { "aws" => "aws", "linode" => "lo", "vultr" => "vu", + "contabo" => "cnt", _ => config_provider, } } @@ -924,6 +952,25 @@ fn resolve_remote_cloud_credentials(provider: &str) -> serde_json::Map { + // Contabo uses four credentials: OAuth2 client_id/secret + API user/password. + if let Some(v) = first_non_empty_env(&["STACKER_CONTABO_CLIENT_ID", "CNT_CLIENT_ID"]) { + creds.insert("cloud_key".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = + first_non_empty_env(&["STACKER_CONTABO_CLIENT_SECRET", "CNT_CLIENT_SECRET"]) + { + creds.insert("cloud_token".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = first_non_empty_env(&["STACKER_CONTABO_API_USER", "CNT_API_USER"]) { + creds.insert("cloud_user".to_string(), serde_json::Value::String(v)); + } + if let Some(v) = + first_non_empty_env(&["STACKER_CONTABO_API_PASSWORD", "CNT_API_PASSWORD"]) + { + creds.insert("cloud_password".to_string(), serde_json::Value::String(v)); + } + } _ => {} } @@ -990,7 +1037,9 @@ fn build_remote_deploy_payload(config: &StackerConfig) -> serde_json::Value { .filter(|v| !v.is_empty()) .unwrap_or_else(|| "custom-stack".to_string()); let os = match provider.as_str() { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) + "cnt" => "ubuntu-22.04", // Contabo: standard Ubuntu image _ => "ubuntu-22.04", }; @@ -1647,11 +1696,17 @@ mod tests { let result = strategy.deploy(&config, &context, &executor).unwrap(); assert_eq!(result.target, DeployTarget::Local); - assert!(result.message.contains("validated")); + assert!(result.message.contains("dry-run") || result.message.contains("previewed"), + "dry-run message should indicate preview, got: {}", result.message); - let args = executor.last_args(); - assert!(args.contains(&"config".to_string())); - assert!(!args.contains(&"up".to_string())); + // Dry-run should NOT invoke docker at all (no compose call) + let recorded = executor.recorded_calls.lock().unwrap(); + // Only the compose-version probe may have been called (from resolve_compose_cmd), + // but the actual compose up/config should NOT be called. + assert!(!recorded.iter().any(|(_, args)| args.contains(&"up".to_string())), + "dry-run must not call docker compose up"); + assert!(!recorded.iter().any(|(_, args)| args.contains(&"config".to_string())), + "dry-run must not call docker compose config"); } #[test] @@ -1703,7 +1758,7 @@ mod tests { .env_file(".env") .build() .unwrap(); - let context = sample_context(true); + let context = sample_context(false); // real deploy, not dry-run let executor = MockExecutor::success(); let strategy = LocalDeploy; diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 0f462e03..42af2bac 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -1958,7 +1958,9 @@ pub fn build_deploy_form(config: &StackerConfig) -> serde_json::Value { let region = cloud.and_then(|c| c.region.clone()).unwrap_or_else(|| "nbg1".to_string()); let server_size = cloud.and_then(|c| c.size.clone()).unwrap_or_else(|| "cpx11".to_string()); let os = match provider.as_str() { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) + "cnt" => "ubuntu-22.04", // Contabo: standard Ubuntu image _ => "ubuntu-22.04", }; diff --git a/src/connectors/user_service/client.rs b/src/connectors/user_service/client.rs index ba7ac22f..df6d55bd 100644 --- a/src/connectors/user_service/client.rs +++ b/src/connectors/user_service/client.rs @@ -216,7 +216,13 @@ impl UserServiceConnector for UserServiceClient { &self, user_id: &str, required_plan_name: &str, + user_token: Option<&str>, ) -> Result { + // "free" plan never requires a subscription check + if required_plan_name.to_lowercase() == "free" { + return Ok(true); + } + let span = tracing::info_span!( "user_service_check_plan", user_id = %user_id, @@ -227,7 +233,11 @@ impl UserServiceConnector for UserServiceClient { let url = format!("{}/oauth_server/api/me", self.base_url); let mut req = self.http_client.get(&url); - if let Some(auth) = self.auth_header() { + // Prefer the user's own token; fall back to service account + let auth = user_token + .map(|t| format!("Bearer {}", t)) + .or_else(|| self.auth_header()); + if let Some(auth) = auth { req = req.header("Authorization", auth); } @@ -256,12 +266,7 @@ impl UserServiceConnector for UserServiceClient { serde_json::from_str::(&text) .map(|response| { let user_plan = response.plan.and_then(|p| p.name).unwrap_or_default(); - // Check if user's plan matches or is higher tier than required - if user_plan.is_empty() || required_plan_name.is_empty() { - return user_plan == required_plan_name; - } - user_plan == required_plan_name - || is_plan_higher_tier(&user_plan, required_plan_name) + is_plan_higher_tier(&user_plan, required_plan_name) }) .map_err(|_| ConnectorError::InvalidResponse(text)) } diff --git a/src/connectors/user_service/connector.rs b/src/connectors/user_service/connector.rs index d6e4feed..e614ef97 100644 --- a/src/connectors/user_service/connector.rs +++ b/src/connectors/user_service/connector.rs @@ -30,11 +30,14 @@ pub trait UserServiceConnector: Send + Sync { async fn list_stacks(&self, user_id: &str) -> Result, ConnectorError>; /// Check if user has access to a specific plan - /// Returns true if user's current plan allows access to required_plan_name + /// Returns true if user's current plan allows access to required_plan_name. + /// Pass `user_token` to authenticate as the user (preferred); falls back to + /// the service account token when `None`. async fn user_has_plan( &self, user_id: &str, required_plan_name: &str, + user_token: Option<&str>, ) -> Result; /// Get user's current plan information diff --git a/src/connectors/user_service/deployment_validator.rs b/src/connectors/user_service/deployment_validator.rs index 77b93770..3eca18fa 100644 --- a/src/connectors/user_service/deployment_validator.rs +++ b/src/connectors/user_service/deployment_validator.rs @@ -135,7 +135,7 @@ impl DeploymentValidator { // For now, we'll rely on User Service to validate the token let has_plan = self .user_service_connector - .user_has_plan(user_token, required_plan) + .user_has_plan(user_token, required_plan, Some(user_token)) .instrument(span.clone()) .await .map_err(|e| DeploymentValidationError::ValidationFailed { diff --git a/src/connectors/user_service/mock.rs b/src/connectors/user_service/mock.rs index da0fbad5..5e8f54ba 100644 --- a/src/connectors/user_service/mock.rs +++ b/src/connectors/user_service/mock.rs @@ -60,6 +60,7 @@ impl UserServiceConnector for MockUserServiceConnector { &self, _user_id: &str, _required_plan_name: &str, + _user_token: Option<&str>, ) -> Result { // Mock always grants access for testing Ok(true) diff --git a/src/connectors/user_service/tests.rs b/src/connectors/user_service/tests.rs index b9525f73..4cc268aa 100644 --- a/src/connectors/user_service/tests.rs +++ b/src/connectors/user_service/tests.rs @@ -109,18 +109,18 @@ async fn test_mock_user_has_plan() { let connector = mock::MockUserServiceConnector; let has_professional = connector - .user_has_plan("user_123", "professional") + .user_has_plan("user_123", "professional", None) .await .unwrap(); assert!(has_professional); let has_enterprise = connector - .user_has_plan("user_123", "enterprise") + .user_has_plan("user_123", "enterprise", None) .await .unwrap(); assert!(has_enterprise); - let has_basic = connector.user_has_plan("user_123", "basic").await.unwrap(); + let has_basic = connector.user_has_plan("user_123", "basic", None).await.unwrap(); assert!(has_basic); } @@ -237,8 +237,23 @@ fn test_is_plan_higher_tier_hierarchy() { // Basic user cannot access enterprise assert!(!is_plan_higher_tier("basic", "enterprise")); - // Same plan should not be considered higher tier - assert!(!is_plan_higher_tier("professional", "professional")); + // Same plan satisfies the requirement + assert!(is_plan_higher_tier("professional", "professional")); + + // Free plan user can access free tier + assert!(is_plan_higher_tier("free", "free")); + + // Free plan user cannot access basic or higher + assert!(!is_plan_higher_tier("free", "basic")); + + // Any paid plan satisfies free requirement + assert!(is_plan_higher_tier("basic", "free")); + assert!(is_plan_higher_tier("professional", "free")); + assert!(is_plan_higher_tier("enterprise", "free")); + + // Case-insensitive comparison + assert!(is_plan_higher_tier("Professional", "professional")); + assert!(is_plan_higher_tier("ENTERPRISE", "basic")); } /// Test UserProfile deserialization with all fields diff --git a/src/connectors/user_service/utils.rs b/src/connectors/user_service/utils.rs index 8931e5df..9af0fb33 100644 --- a/src/connectors/user_service/utils.rs +++ b/src/connectors/user_service/utils.rs @@ -1,13 +1,16 @@ /// Helper function to determine if a plan tier can access a required plan -/// Basic idea: enterprise >= professional >= basic +/// Hierarchy (lowest to highest): free < basic < professional < enterprise pub(crate) fn is_plan_higher_tier(user_plan: &str, required_plan: &str) -> bool { - let plan_hierarchy = vec!["basic", "professional", "enterprise"]; + let plan_hierarchy = vec!["free", "basic", "professional", "enterprise"]; - let user_level = plan_hierarchy.iter().position(|&p| p == user_plan); - let required_level = plan_hierarchy.iter().position(|&p| p == required_plan); + let user_lower = user_plan.to_lowercase(); + let required_lower = required_plan.to_lowercase(); + + let user_level = plan_hierarchy.iter().position(|&p| p == user_lower.as_str()); + let required_level = plan_hierarchy.iter().position(|&p| p == required_lower.as_str()); match (user_level, required_level) { - (Some(user_level), Some(required_level)) => user_level > required_level, + (Some(user_level), Some(required_level)) => user_level >= required_level, // Fail closed if either plan is unknown _ => false, } diff --git a/src/console/commands/cli/config.rs b/src/console/commands/cli/config.rs index 4c1158d5..661ccafb 100644 --- a/src/console/commands/cli/config.rs +++ b/src/console/commands/cli/config.rs @@ -39,7 +39,7 @@ fn parse_cloud_provider(s: &str) -> Result { let json = format!("\"{}\"", s.trim().to_lowercase()); serde_json::from_str::(&json).map_err(|_| { CliError::ConfigValidation( - "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr" + "Invalid cloud provider. Use: hetzner, digitalocean, aws, linode, vultr, contabo" .to_string(), ) }) @@ -52,6 +52,7 @@ fn default_region_for_provider(provider: CloudProvider) -> &'static str { CloudProvider::Aws => "us-east-1", CloudProvider::Linode => "us-east", CloudProvider::Vultr => "ewr", + CloudProvider::Contabo => "EU", } } @@ -62,6 +63,7 @@ fn default_size_for_provider(provider: CloudProvider) -> &'static str { CloudProvider::Aws => "t3.small", CloudProvider::Linode => "g6-standard-2", CloudProvider::Vultr => "vc2-2c-4gb", + CloudProvider::Contabo => "V45", } } @@ -95,6 +97,7 @@ fn provider_code_for_remote(provider: CloudProvider) -> &'static str { CloudProvider::Aws => "aws", CloudProvider::Linode => "lo", CloudProvider::Vultr => "vu", + CloudProvider::Contabo => "cnt", } } @@ -214,7 +217,8 @@ pub fn run_generate_remote_payload( .unwrap_or_else(|| "custom-stack".to_string()); let provider_code = provider_code_for_remote(provider); let os = match provider_code { - "do" => "docker-20-04", + "do" => "docker-20-04", // DigitalOcean marketplace image with Docker pre-installed + "htz" => "docker-ce", // Hetzner snapshot with Docker CE pre-installed (Ubuntu 24.04) _ => "ubuntu-22.04", }; diff --git a/src/console/commands/cli/update.rs b/src/console/commands/cli/update.rs index b6ec57f8..2b82203f 100644 --- a/src/console/commands/cli/update.rs +++ b/src/console/commands/cli/update.rs @@ -1,8 +1,16 @@ use crate::cli::error::CliError; use crate::console::commands::CallableTrait; +use flate2::read::GzDecoder; +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; const DEFAULT_CHANNEL: &str = "stable"; const VALID_CHANNELS: &[&str] = &["stable", "beta"]; +const GITHUB_API_RELEASES: &str = + "https://api.github.com/repos/trydirect/stacker/releases"; +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Parse and validate a release channel string. pub fn parse_channel(channel: Option<&str>) -> Result { @@ -18,6 +26,131 @@ pub fn parse_channel(channel: Option<&str>) -> Result { } } +/// Detect the current platform's asset suffix used in GitHub release filenames. +/// Format: `stacker-v{VERSION}-{arch}-{os}.tar.gz` +fn detect_asset_suffix() -> String { + let os = if cfg!(target_os = "macos") { + "darwin" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "aarch64") { + "aarch64" + } else { + "x86_64" + }; + format!("{}-{}", arch, os) +} + +#[derive(Debug, serde::Deserialize)] +struct GithubRelease { + tag_name: String, + prerelease: bool, + assets: Vec, +} + +#[derive(Debug, serde::Deserialize)] +struct GithubAsset { + name: String, + browser_download_url: String, +} + +/// Fetch the latest release from GitHub that matches the channel. +/// - "stable" → non-prerelease releases +/// - "beta" → prerelease releases +fn fetch_latest_release(channel: &str) -> Result, Box> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + + let releases: Vec = client + .get(GITHUB_API_RELEASES) + .send()? + .error_for_status()? + .json()?; + + let want_prerelease = channel == "beta"; + let release = releases + .into_iter() + .find(|r| r.prerelease == want_prerelease || (!want_prerelease && !r.prerelease)); + + Ok(release) +} + +/// Compare two semver strings (major.minor.patch) — returns true if `latest` > `current`. +fn is_newer(current: &str, latest: &str) -> bool { + let parse = |v: &str| -> Option<(u64, u64, u64)> { + let v = v.trim_start_matches('v'); + let parts: Vec<&str> = v.splitn(3, '.').collect(); + if parts.len() < 3 { + return None; + } + Some(( + parts[0].parse().ok()?, + parts[1].parse().ok()?, + parts[2].split('-').next()?.parse().ok()?, + )) + }; + match (parse(current), parse(latest)) { + (Some(c), Some(l)) => l > c, + _ => false, + } +} + +/// Download `url` into a temporary file and return its path. +fn download_to_tempfile(url: &str) -> Result> { + let client = reqwest::blocking::Client::builder() + .user_agent(format!("stacker-cli/{}", CURRENT_VERSION)) + .build()?; + let mut resp = client.get(url).send()?.error_for_status()?; + let mut tmp = tempfile::NamedTempFile::new()?; + io::copy(&mut resp, &mut tmp)?; + Ok(tmp) +} + +/// Extract the `stacker` binary from a `.tar.gz` archive and return its bytes. +fn extract_binary_from_targz(tmp: &tempfile::NamedTempFile) -> Result, Box> { + let file = fs::File::open(tmp.path())?; + let gz = GzDecoder::new(file); + let mut archive = tar::Archive::new(gz); + for entry in archive.entries()? { + let mut entry: tar::Entry> = entry?; + let path = entry.path()?.to_path_buf(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name == "stacker" { + let mut buf = Vec::new(); + io::copy(&mut entry, &mut buf)?; + return Ok(buf); + } + } + Err("stacker binary not found in archive".into()) +} + +/// Replace the running executable with `new_bytes`. +fn replace_current_exe(new_bytes: Vec) -> Result<(), Box> { + let current_exe: PathBuf = env::current_exe()?; + + // Write new binary to a sibling temp file, then atomically rename. + let parent = current_exe.parent().ok_or("Cannot determine binary parent directory")?; + let mut tmp = tempfile::Builder::new() + .prefix(".stacker-update-") + .tempfile_in(parent)?; + io::Write::write_all(&mut tmp, &new_bytes)?; + + // Make executable (Unix) + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tmp.as_file().metadata()?.permissions(); + perms.set_mode(0o755); + tmp.as_file().set_permissions(perms)?; + } + + let (_, tmp_path) = tmp.keep()?; + fs::rename(&tmp_path, ¤t_exe)?; + Ok(()) +} + /// `stacker update [--channel stable|beta]` /// /// Checks for updates and self-updates the stacker binary. @@ -35,7 +168,48 @@ impl CallableTrait for UpdateCommand { fn call(&self) -> Result<(), Box> { let channel = parse_channel(self.channel.as_deref())?; eprintln!("Checking for updates on '{}' channel...", channel); - eprintln!("You are running the latest version."); + + let release = match fetch_latest_release(&channel)? { + Some(r) => r, + None => { + eprintln!("No releases found on '{}' channel.", channel); + return Ok(()); + } + }; + + let latest_version = release.tag_name.trim_start_matches('v'); + + if !is_newer(CURRENT_VERSION, latest_version) { + eprintln!( + "You are running the latest version (v{}).", + CURRENT_VERSION + ); + return Ok(()); + } + + eprintln!( + "New version available: v{} (you have v{}). Updating...", + latest_version, CURRENT_VERSION + ); + + let suffix = detect_asset_suffix(); + let asset_name = format!("stacker-v{}-{}.tar.gz", latest_version, suffix); + let asset = release + .assets + .iter() + .find(|a| a.name == asset_name) + .ok_or_else(|| format!("No release asset found for your platform: {}", asset_name))?; + + eprintln!("Downloading {}...", asset.name); + let tmp = download_to_tempfile(&asset.browser_download_url)?; + + eprintln!("Extracting..."); + let new_bytes = extract_binary_from_targz(&tmp)?; + + eprintln!("Installing..."); + replace_current_exe(new_bytes)?; + + eprintln!("✅ Updated to v{}. Run 'stacker --version' to confirm.", latest_version); Ok(()) } } @@ -65,4 +239,23 @@ mod tests { fn test_parse_channel_rejects_unknown() { assert!(parse_channel(Some("nightly")).is_err()); } + + #[test] + fn test_is_newer_detects_update() { + assert!(is_newer("0.2.4", "0.2.5")); + assert!(is_newer("0.2.4", "0.3.0")); + assert!(is_newer("0.2.4", "1.0.0")); + } + + #[test] + fn test_is_newer_no_update_needed() { + assert!(!is_newer("0.2.5", "0.2.5")); + assert!(!is_newer("0.2.5", "0.2.4")); + } + + #[test] + fn test_is_newer_handles_v_prefix() { + assert!(is_newer("0.2.4", "v0.2.5")); + assert!(!is_newer("v0.2.5", "v0.2.5")); + } } diff --git a/src/db/marketplace.rs b/src/db/marketplace.rs index 0b85346f..8e7099fb 100644 --- a/src/db/marketplace.rs +++ b/src/db/marketplace.rs @@ -31,7 +31,8 @@ pub async fn list_approved( t.currency, t.created_at, t.updated_at, - t.approved_at + t.approved_at, + t.verifications FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.status = 'approved'"#, @@ -45,9 +46,16 @@ pub async fn list_approved( } match sort.unwrap_or("recent") { - "popular" => base.push_str(" ORDER BY t.deploy_count DESC, t.view_count DESC"), - "rating" => base.push_str(" ORDER BY (SELECT AVG(rate) FROM rating WHERE rating.product_id = t.product_id) DESC NULLS LAST"), - _ => base.push_str(" ORDER BY t.approved_at DESC NULLS LAST, t.created_at DESC"), + // Hardened images always float to the top of each sort bucket + "popular" => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, t.deploy_count DESC, t.view_count DESC", + ), + "rating" => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, (SELECT AVG(rate) FROM rating WHERE rating.product_id = t.product_id) DESC NULLS LAST", + ), + _ => base.push_str( + " ORDER BY (t.verifications @> '{\"hardened_images\":true}') DESC, t.approved_at DESC NULLS LAST, t.created_at DESC", + ), } let query_span = tracing::info_span!("marketplace_list_approved"); @@ -115,7 +123,8 @@ pub async fn get_by_slug_and_user( t.currency, t.created_at, t.updated_at, - t.approved_at + t.approved_at, + t.verifications FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.slug = $1 AND t.creator_user_id = $2"#, @@ -137,8 +146,7 @@ pub async fn get_by_slug_with_latest( ) -> Result<(StackTemplate, Option), String> { let query_span = tracing::info_span!("marketplace_get_by_slug_with_latest", slug = %slug); - let template = sqlx::query_as!( - StackTemplate, + let template = sqlx::query_as::<_, StackTemplate>( r#"SELECT t.id, t.creator_user_id, @@ -147,7 +155,7 @@ pub async fn get_by_slug_with_latest( t.slug, t.short_description, t.long_description, - c.name AS "category_code?", + c.name AS "category_code", t.product_id, t.tags, t.tech_stack, @@ -161,12 +169,13 @@ pub async fn get_by_slug_with_latest( t.currency, t.created_at, t.updated_at, - t.approved_at + t.approved_at, + t.verifications FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.slug = $1 AND t.status = 'approved'"#, - slug ) + .bind(slug) .fetch_one(pool) .instrument(query_span.clone()) .await @@ -206,8 +215,7 @@ pub async fn get_by_id( ) -> Result, String> { let query_span = tracing::info_span!("marketplace_get_by_id", id = %template_id); - let template = sqlx::query_as!( - StackTemplate, + let template = sqlx::query_as::<_, StackTemplate>( r#"SELECT t.id, t.creator_user_id, @@ -216,7 +224,7 @@ pub async fn get_by_id( t.slug, t.short_description, t.long_description, - c.name AS "category_code?", + c.name AS "category_code", t.product_id, t.tags, t.tech_stack, @@ -230,12 +238,13 @@ pub async fn get_by_id( t.required_plan_name, t.price, t.billing_cycle, - t.currency + t.currency, + t.verifications FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.id = $1"#, - template_id ) + .bind(template_id) .fetch_optional(pool) .instrument(query_span) .await @@ -266,8 +275,7 @@ pub async fn create_draft( let price_f64 = price; - let rec = sqlx::query_as!( - StackTemplate, + let rec = sqlx::query_as::<_, StackTemplate>( r#"INSERT INTO stack_template ( creator_user_id, creator_name, name, slug, short_description, long_description, category_id, @@ -281,7 +289,7 @@ pub async fn create_draft( slug, short_description, long_description, - (SELECT name FROM stack_category WHERE id = category_id) AS "category_code?", + (SELECT name FROM stack_category WHERE id = category_id) AS "category_code", product_id, tags, tech_stack, @@ -295,21 +303,22 @@ pub async fn create_draft( currency, created_at, updated_at, - approved_at + approved_at, + verifications "#, - creator_user_id, - creator_name, - name, - slug, - short_description, - long_description, - category_code, - tags, - tech_stack, - price_f64, - billing_cycle, - currency ) + .bind(creator_user_id) + .bind(creator_name) + .bind(name) + .bind(slug) + .bind(short_description) + .bind(long_description) + .bind(category_code) + .bind(tags) + .bind(tech_stack) + .bind(price_f64) + .bind(billing_cycle) + .bind(currency) .fetch_one(pool) .instrument(query_span) .await @@ -551,8 +560,7 @@ pub async fn resubmit_with_new_version( pub async fn list_mine(pool: &PgPool, user_id: &str) -> Result, String> { let query_span = tracing::info_span!("marketplace_list_mine", user = %user_id); - sqlx::query_as!( - StackTemplate, + sqlx::query_as::<_, StackTemplate>( r#"SELECT t.id, t.creator_user_id, @@ -561,7 +569,7 @@ pub async fn list_mine(pool: &PgPool, user_id: &str) -> Result Result Result Result, String> { let query_span = tracing::info_span!("marketplace_admin_list_submitted"); - sqlx::query_as!( - StackTemplate, + sqlx::query_as::<_, StackTemplate>( r#"SELECT t.id, t.creator_user_id, @@ -604,7 +612,7 @@ pub async fn admin_list_submitted(pool: &PgPool) -> Result, S t.slug, t.short_description, t.long_description, - c.name AS "category_code?", + c.name AS "category_code", t.product_id, t.tags, t.tech_stack, @@ -618,7 +626,8 @@ pub async fn admin_list_submitted(pool: &PgPool) -> Result, S t.currency, t.created_at, t.updated_at, - t.approved_at + t.approved_at, + t.verifications FROM stack_template t LEFT JOIN stack_category c ON t.category_id = c.id WHERE t.status IN ('submitted', 'approved') @@ -627,7 +636,7 @@ pub async fn admin_list_submitted(pool: &PgPool) -> Result, S WHEN 'submitted' THEN 0 WHEN 'approved' THEN 1 END, - t.created_at ASC"# + t.created_at ASC"#, ) .fetch_all(pool) .instrument(query_span) @@ -938,3 +947,76 @@ pub async fn save_security_scan( "Internal Server Error".to_string() }) } + +/// Admin: update pricing fields on any template regardless of status. +/// Normalizes price to 0 when billing_cycle is "free". +pub async fn admin_update_pricing( + pool: &PgPool, + template_id: &uuid::Uuid, + price: Option, + billing_cycle: Option<&str>, + required_plan_name: Option<&str>, + currency: Option<&str>, +) -> Result { + let query_span = tracing::info_span!( + "marketplace_admin_update_pricing", + template_id = %template_id + ); + + // Normalize price=0 when billing_cycle is "free" + let normalized_price = match billing_cycle { + Some("free") => Some(0.0_f64), + _ => price, + }; + + let res = sqlx::query( + r#"UPDATE stack_template SET + price = COALESCE($2, price), + billing_cycle = COALESCE($3, billing_cycle), + required_plan_name = COALESCE($4, required_plan_name), + currency = COALESCE($5, currency) + WHERE id = $1"#, + ) + .bind(*template_id) + .bind(normalized_price) + .bind(billing_cycle) + .bind(required_plan_name) + .bind(currency) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("admin_update_pricing error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} + +/// Merge `updates` into the `verifications` JSONB column on a template. +/// Uses the PostgreSQL `||` operator so only the provided keys are overwritten. +pub async fn update_verifications( + pool: &PgPool, + template_id: &uuid::Uuid, + updates: serde_json::Value, +) -> Result { + let query_span = + tracing::info_span!("marketplace_update_verifications", template_id = %template_id); + + let res = sqlx::query( + r#"UPDATE stack_template + SET verifications = verifications || $2 + WHERE id = $1"#, + ) + .bind(*template_id) + .bind(&updates) + .execute(pool) + .instrument(query_span) + .await + .map_err(|e| { + tracing::error!("update_verifications error: {:?}", e); + "Internal Server Error".to_string() + })?; + + Ok(res.rows_affected() > 0) +} diff --git a/src/helpers/mq_manager.rs b/src/helpers/mq_manager.rs index be33b458..6729636e 100644 --- a/src/helpers/mq_manager.rs +++ b/src/helpers/mq_manager.rs @@ -52,6 +52,28 @@ impl MqManager { }) } + async fn declare_exchange(channel: &Channel, exchange: &str) -> Result<(), String> { + channel + .exchange_declare( + exchange, + ExchangeKind::Topic, + ExchangeDeclareOptions { + passive: false, + durable: true, + auto_delete: false, + internal: false, + nowait: false, + }, + FieldTable::default(), + ) + .await + .map_err(|err| { + let msg = format!("declaring exchange '{}': {:?}", exchange, err); + tracing::error!(msg); + msg + }) + } + pub async fn publish( &self, exchange: String, @@ -60,8 +82,9 @@ impl MqManager { ) -> Result { let payload = serde_json::to_string::(msg).map_err(|err| format!("{:?}", err))?; - self.create_channel() - .await? + let channel = self.create_channel().await?; + Self::declare_exchange(&channel, &exchange).await?; + channel .basic_publish( exchange.as_str(), routing_key.as_str(), @@ -108,26 +131,17 @@ impl MqManager { ) -> Result { let channel = self.create_channel().await?; - channel - .exchange_declare( - exchange_name, - ExchangeKind::Topic, - ExchangeDeclareOptions { - passive: false, - durable: true, - auto_delete: false, - internal: false, - nowait: false, - }, - FieldTable::default(), - ) + Self::declare_exchange(&channel, exchange_name) .await - .expect("Exchange declare failed"); + .map_err(|e| { + tracing::error!("Exchange declare failed: {}", e); + e + })?; let mut args = FieldTable::default(); args.insert("x-expires".into(), AMQPValue::LongUInt(3600000)); - let _queue = channel + channel .queue_declare( queue_name, QueueDeclareOptions { @@ -140,9 +154,13 @@ impl MqManager { args, ) .await - .expect("Queue declare failed"); + .map_err(|err| { + let msg = format!("declaring queue '{}': {:?}", queue_name, err); + tracing::error!(msg); + msg + })?; - let _ = channel + channel .queue_bind( queue_name, exchange_name, @@ -151,7 +169,11 @@ impl MqManager { FieldTable::default(), ) .await - .map_err(|err| format!("error {:?}", err)); + .map_err(|err| { + let msg = format!("binding queue '{}' to exchange '{}': {:?}", queue_name, exchange_name, err); + tracing::error!(msg); + msg + })?; let channel = self.create_channel().await?; Ok(channel) diff --git a/src/helpers/security_validator.rs b/src/helpers/security_validator.rs index dcaef6a7..a72554d6 100644 --- a/src/helpers/security_validator.rs +++ b/src/helpers/security_validator.rs @@ -18,6 +18,8 @@ pub struct SecurityReport { pub no_hardcoded_creds: SecurityCheckResult, pub valid_docker_syntax: SecurityCheckResult, pub no_malicious_code: SecurityCheckResult, + /// Whether images follow hardened-image practices (non-blocking quality check). + pub hardened_images: SecurityCheckResult, pub overall_passed: bool, pub risk_score: u32, // 0-100, lower is better pub recommendations: Vec, @@ -31,6 +33,7 @@ impl SecurityReport { "no_hardcoded_creds": self.no_hardcoded_creds.passed, "valid_docker_syntax": self.valid_docker_syntax.passed, "no_malicious_code": self.no_malicious_code.passed, + "hardened_images": self.hardened_images.passed, }) } } @@ -86,6 +89,20 @@ const KNOWN_CRYPTO_MINER_PATTERNS: &[&str] = &[ "monero", "coinhive", "coin-hive", ]; +/// Docker image namespace/registry prefixes known to publish security-hardened images. +/// Chainguard (cgr.dev), Google Distroless, Amazon ECR Public official, +/// RapidFort, and Bitnami all apply automated CVE scanning + minimal-OS hardening. +/// Docker Official Images have no namespace separator (e.g. "nginx:1.25", "redis:7"). +const KNOWN_HARDENED_SOURCES: &[&str] = &[ + "cgr.dev/", // Chainguard hardened/distroless images + "gcr.io/distroless/", // Google Distroless + "public.ecr.aws/", // Amazon ECR Public official images + "rapidfort/", // RapidFort minimal hardened images + "bitnami/", // Bitnami (Broadcom) hardened images + "ironbank/", // DoD Iron Bank hardened images + "registry1.dso.mil/", // DoD Iron Bank registry +]; + /// Normalize a JSON-pretty-printed string into a YAML-like format so that /// regex patterns designed for docker-compose YAML also match JSON input. /// @@ -119,11 +136,13 @@ pub fn validate_stack_security(stack_definition: &Value) -> SecurityReport { let no_hardcoded_creds = check_no_hardcoded_creds(&definition_str); let valid_docker_syntax = check_valid_docker_syntax(stack_definition, &definition_str); let no_malicious_code = check_no_malicious_code(&definition_str); + let hardened_images = check_hardened_images(stack_definition); let overall_passed = no_secrets.passed && no_hardcoded_creds.passed && valid_docker_syntax.passed && no_malicious_code.passed; + // hardened_images is a quality indicator — it does NOT block overall_passed // Calculate risk score (0-100) let mut risk_score: u32 = 0; @@ -164,12 +183,16 @@ pub fn validate_stack_security(stack_definition: &Value) -> SecurityReport { if risk_score == 0 { recommendations.push("Automated scan passed. AI review recommended for deeper analysis.".to_string()); } + if !hardened_images.passed { + recommendations.push("Consider using images from hardened sources (Chainguard, Bitnami, Google Distroless) and pinning all tags to specific versions.".to_string()); + } SecurityReport { no_secrets, no_hardcoded_creds, valid_docker_syntax, no_malicious_code, + hardened_images, overall_passed, risk_score, recommendations, @@ -388,6 +411,134 @@ fn check_no_malicious_code(content: &str) -> SecurityCheckResult { } } +/// Returns true for an image reference that is from a known hardened source, +/// or is a Docker Official Image (no `/` separator in the name part, e.g. `nginx:1.25`). +fn is_from_hardened_source(image: &str) -> bool { + // Strip optional registry prefix when checking known sources + for prefix in KNOWN_HARDENED_SOURCES { + if image.starts_with(prefix) { + return true; + } + } + // Docker Official Images have no namespace (no '/' before the tag separator ':') + // e.g. "nginx:1.25", "redis:7-alpine", "postgres:16" — maintained by Docker, Inc. + // We detect this by checking there is no '/' in the name before the first ':'. + let name_part = image.split(':').next().unwrap_or(image); + !name_part.contains('/') +} + +/// Check whether services use hardened image practices: +/// 1. No `:latest` or untagged images (reproducibility). +/// 2. At least one service uses a non-root user OR images from known hardened sources. +/// 3. Digest-pinned images (`image@sha256:`) score as fully hardened. +/// +/// This is a quality/advisory check — it does NOT block `overall_passed`. +fn check_hardened_images(stack_definition: &Value) -> SecurityCheckResult { + let mut findings: Vec = Vec::new(); + let mut positives: Vec = Vec::new(); + + let services = match stack_definition.get("services").and_then(|s| s.as_object()) { + Some(s) => s, + None => { + return SecurityCheckResult { + passed: false, + severity: "info".to_string(), + message: "Cannot analyse images: no services found".to_string(), + details: vec![], + }; + } + }; + + let mut total_images: usize = 0; + let mut pinned_count: usize = 0; + let mut hardened_source_count: usize = 0; + let mut non_root_count: usize = 0; + let mut read_only_count: usize = 0; + + for (name, service) in services { + // Check image tag quality + if let Some(image) = service.get("image").and_then(|v| v.as_str()) { + total_images += 1; + + if image.contains("@sha256:") { + pinned_count += 1; + positives.push(format!("Service '{}': image pinned to digest ({})", name, image)); + } else if image.ends_with(":latest") { + findings.push(format!( + "[WARNING] Service '{}' uses ':latest' tag — not reproducible and may silently receive unsafe updates ({})", + name, image + )); + } else if !image.contains(':') { + findings.push(format!( + "[WARNING] Service '{}' has no tag — defaults to ':latest' implicitly ({})", + name, image + )); + } else { + pinned_count += 1; // versioned tag counts as pinned + } + + if is_from_hardened_source(image) { + hardened_source_count += 1; + positives.push(format!("Service '{}': image from hardened/trusted source ({})", name, image)); + } + } + + // Check for non-root user + if let Some(user) = service.get("user").and_then(|v| v.as_str()) { + let is_root = user == "root" || user == "0" || user.starts_with("0:"); + if !is_root { + non_root_count += 1; + positives.push(format!("Service '{}': runs as non-root user ({})", name, user)); + } else { + findings.push(format!( + "[INFO] Service '{}' explicitly runs as root — consider a non-root user", + name + )); + } + } + + // Check for read-only root filesystem + if service.get("read_only").and_then(|v| v.as_bool()) == Some(true) { + read_only_count += 1; + positives.push(format!("Service '{}': read-only root filesystem enabled", name)); + } + } + + // Determine pass/fail: + // Pass requires ALL images to have versioned tags AND at least one hardened-source + // or non-root signal. A single service with a `:latest` tag is a failure. + let unpinned_warnings = findings.iter().filter(|f| f.contains("[WARNING]")).count(); + let passed = unpinned_warnings == 0 + && total_images > 0 + && (hardened_source_count > 0 || non_root_count > 0 || read_only_count > 0 || pinned_count == total_images); + + let mut details = findings.clone(); + details.extend(positives); + + SecurityCheckResult { + passed, + severity: if unpinned_warnings > 0 { + "warning".to_string() + } else if passed { + "info".to_string() + } else { + "info".to_string() + }, + message: if passed { + format!( + "Images follow hardened practices ({} pinned, {} from hardened sources, {} non-root)", + pinned_count, hardened_source_count, non_root_count + ) + } else { + format!( + "{} image(s) use unpinned/latest tags or lack hardening signals", + unpinned_warnings.max(if total_images == 0 { 1 } else { 0 }) + ) + }, + details, + } +} + #[cfg(test)] mod tests { use super::*; @@ -475,4 +626,101 @@ mod tests { let report = validate_stack_security(&definition); assert!(!report.valid_docker_syntax.passed); } + + #[test] + fn test_hardened_images_passes_for_official_versioned() { + // Docker Official Images (nginx, postgres) with pinned versions — should pass + let definition = json!({ + "services": { + "web": { "image": "nginx:1.25" }, + "db": { "image": "postgres:16" } + } + }); + let result = check_hardened_images(&definition); + assert!(result.passed, "Official images with versioned tags should pass: {}", result.message); + } + + #[test] + fn test_hardened_images_fails_for_latest() { + let definition = json!({ + "services": { + "web": { "image": "nginx:latest" } + } + }); + let result = check_hardened_images(&definition); + assert!(!result.passed, "':latest' tag should fail hardened-images check"); + } + + #[test] + fn test_hardened_images_fails_for_untagged() { + let definition = json!({ + "services": { + "web": { "image": "nginx" } + } + }); + let result = check_hardened_images(&definition); + assert!(!result.passed, "Untagged image should fail hardened-images check"); + } + + #[test] + fn test_hardened_images_passes_for_chainguard() { + let definition = json!({ + "services": { + "web": { "image": "cgr.dev/chainguard/nginx:latest" } + } + }); + // Even ':latest' on cgr.dev is pinned via digest under the hood, but our + // static check currently only exempts known-hardened-source prefix from the + // non-root/digest requirement, while still flagging ':latest' as a warning. + // This test verifies the hardened-source is detected. + let result = check_hardened_images(&definition); + assert!(result.details.iter().any(|d| d.contains("hardened/trusted source")), + "Chainguard image should be recognised as hardened source"); + } + + #[test] + fn test_hardened_images_passes_for_non_root_user() { + let definition = json!({ + "services": { + "app": { + "image": "myapp:2.0", + "user": "1001" + } + } + }); + let result = check_hardened_images(&definition); + assert!(result.passed, "Versioned image + non-root user should pass: {}", result.message); + } + + #[test] + fn test_hardened_images_digest_pinned() { + let definition = json!({ + "services": { + "app": { + "image": "nginx@sha256:abc123def456abc123def456abc123def456abc123def456abc123def456ab12" + } + } + }); + let result = check_hardened_images(&definition); + assert!(result.passed, "Digest-pinned image should pass: {}", result.message); + assert!(result.details.iter().any(|d| d.contains("pinned to digest"))); + } + + #[test] + fn test_hardened_check_does_not_block_overall_passed() { + // A stack with ':latest' tags should still pass overall security (no secrets etc.) + // but hardened_images check should fail on its own + let definition = json!({ + "version": "3.8", + "services": { + "web": { + "image": "nginx:latest", + "ports": ["80:80"] + } + } + }); + let report = validate_stack_security(&definition); + assert!(report.overall_passed, "':latest' tag should NOT block overall_passed"); + assert!(!report.hardened_images.passed, "':latest' tag should fail hardened_images check"); + } } diff --git a/src/models/marketplace.rs b/src/models/marketplace.rs index e6a35abf..f7bb6cee 100644 --- a/src/models/marketplace.rs +++ b/src/models/marketplace.rs @@ -34,6 +34,7 @@ pub struct StackTemplate { pub created_at: Option>, pub updated_at: Option>, pub approved_at: Option>, + pub verifications: serde_json::Value, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, sqlx::FromRow)] diff --git a/src/routes/dockerhub/mod.rs b/src/routes/dockerhub/mod.rs index 4704d125..83215ad1 100644 --- a/src/routes/dockerhub/mod.rs +++ b/src/routes/dockerhub/mod.rs @@ -2,8 +2,9 @@ use std::sync::Arc; use crate::connectors::{DockerHubConnector, NamespaceSummary, RepositorySummary, TagSummary}; use crate::helpers::JsonResponse; -use actix_web::{get, web, Error, Responder}; +use actix_web::{get, post, web, Error, HttpResponse, Responder}; use serde::Deserialize; +use serde_json::Value; #[derive(Deserialize, Debug)] pub struct AutocompleteQuery { @@ -86,6 +87,16 @@ pub async fn list_tags( .map_err(Error::from) } +/// Receive a DockerHub autocomplete analytics event from the stack builder UI. +/// The payload is `{event: string, payload: any}` — logged and discarded. +/// Returns 204 No Content so the browser's fire-and-forget fetch succeeds. +#[tracing::instrument(name = "dockerhub_log_event", skip(body))] +#[post("/events")] +pub async fn log_event(body: web::Json) -> HttpResponse { + tracing::debug!(event = ?body, "dockerhub autocomplete event received"); + HttpResponse::NoContent().finish() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/routes/marketplace/admin.rs b/src/routes/marketplace/admin.rs index 9d6cf20c..6e3a7dda 100644 --- a/src/routes/marketplace/admin.rs +++ b/src/routes/marketplace/admin.rs @@ -4,7 +4,7 @@ use crate::db; use crate::helpers::security_validator; use crate::helpers::JsonResponse; use crate::models; -use actix_web::{get, post, web, Responder, Result}; +use actix_web::{get, patch, post, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; use tracing::Instrument; @@ -293,6 +293,25 @@ pub async fn security_scan_handler( .await .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + // Always persist the hardened_images result (true/false) regardless of overall scan outcome. + // security_reviewed is only set when the scan passes all gates. + { + let mut verif_patch = serde_json::json!({}); + verif_patch["hardened_images"] = serde_json::Value::Bool(report.hardened_images.passed); + if report.overall_passed { + verif_patch["security_reviewed"] = serde_json::Value::Bool(true); + } + if let Err(e) = db::marketplace::update_verifications( + pg_pool.get_ref(), + &id, + verif_patch, + ) + .await + { + tracing::warn!("Failed to auto-set verifications after scan: {}", e); + } + } + let result = serde_json::json!({ "template_id": template.id, "template_name": template.name, @@ -342,3 +361,114 @@ pub async fn list_plans_handler( JsonResponse::build().set_list(plan_json).ok("OK") }) } + +#[derive(serde::Deserialize, Debug)] +pub struct AdminPricingRequest { + pub price: Option, + pub billing_cycle: Option, + pub required_plan_name: Option, + pub currency: Option, +} + +#[tracing::instrument(name = "Admin update template pricing")] +#[patch("/{id}/pricing")] +pub async fn pricing_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let req = body.into_inner(); + let updated = db::marketplace::admin_update_pricing( + pg_pool.get_ref(), + &id, + req.price, + req.billing_cycle.as_deref(), + req.required_plan_name.as_deref(), + req.currency.as_deref(), + ) + .await + .map_err(|err| JsonResponse::::build().bad_request(err))?; + + if updated { + Ok(JsonResponse::::build().ok("Updated")) + } else { + Err(JsonResponse::::build().not_found("Template not found")) + } +} + +/// Request body for PATCH /{id}/verifications. +/// Each key is a boolean flag. Unknown keys are accepted and stored as-is. +/// Omitted keys are not touched (partial update via JSONB `||`). +#[derive(serde::Deserialize, Debug)] +pub struct AdminVerificationsRequest { + pub security_reviewed: Option, + pub https_ready: Option, + pub open_source: Option, + pub maintained: Option, + pub vulnerability_scanned: Option, + /// Whether the stack uses hardened Docker images (auto-detected by security scan, + /// but can also be set manually by the admin). + pub hardened_images: Option, +} + +#[tracing::instrument(name = "Admin update template verifications")] +#[patch("/{id}/verifications")] +pub async fn update_verifications_handler( + _admin: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, + body: web::Json, +) -> Result>> { + let id = uuid::Uuid::parse_str(&path.into_inner().0) + .map_err(|_| actix_web::error::ErrorBadRequest("Invalid UUID"))?; + + let req = body.into_inner(); + + // Build a partial JSONB patch containing only the supplied fields + let mut patch = serde_json::Map::new(); + if let Some(v) = req.security_reviewed { + patch.insert("security_reviewed".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.https_ready { + patch.insert("https_ready".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.open_source { + patch.insert("open_source".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.maintained { + patch.insert("maintained".to_string(), serde_json::Value::Bool(v)); + } + if let Some(v) = req.vulnerability_scanned { + patch.insert( + "vulnerability_scanned".to_string(), + serde_json::Value::Bool(v), + ); + } + if let Some(v) = req.hardened_images { + patch.insert("hardened_images".to_string(), serde_json::Value::Bool(v)); + } + + if patch.is_empty() { + return Err( + JsonResponse::::build().bad_request("No verification flags provided") + ); + } + + let updated = db::marketplace::update_verifications( + pg_pool.get_ref(), + &id, + serde_json::Value::Object(patch), + ) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + + if updated { + Ok(JsonResponse::::build().ok("Verifications updated")) + } else { + Err(JsonResponse::::build().not_found("Template not found")) + } +} diff --git a/src/routes/marketplace/creator.rs b/src/routes/marketplace/creator.rs index a65ab55e..31b2b711 100644 --- a/src/routes/marketplace/creator.rs +++ b/src/routes/marketplace/creator.rs @@ -169,6 +169,12 @@ pub async fn update_handler( let req = body.into_inner(); + // Normalize pricing: plan_type "free" forces price to 0 + let price = match req.plan_type.as_deref() { + Some("free") => Some(0.0), + _ => req.price, + }; + let updated = db::marketplace::update_metadata( pg_pool.get_ref(), &id, @@ -178,7 +184,7 @@ pub async fn update_handler( req.category_code.as_deref(), req.tags, req.tech_stack, - req.price, + price, req.plan_type.as_deref(), req.currency.as_deref(), ) diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index f42fe346..b4efd611 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -55,7 +55,7 @@ pub async fn item( // If template requires a specific plan, validate user has it if let Some(required_plan) = template.required_plan_name { let has_plan = user_service - .user_has_plan(&user.id, &required_plan) + .user_has_plan(&user.id, &required_plan, user.access_token.as_deref()) .await .map_err(|err| { tracing::error!("Failed to validate plan: {:?}", err); @@ -411,7 +411,7 @@ pub async fn saved_item( // If template requires a specific plan, validate user has it if let Some(required_plan) = template.required_plan_name { let has_plan = user_service - .user_has_plan(&user.id, &required_plan) + .user_has_plan(&user.id, &required_plan, user.access_token.as_deref()) .await .map_err(|err| { tracing::error!("Failed to validate plan: {:?}", err); diff --git a/src/startup.rs b/src/startup.rs index de9bdc05..9704ae75 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -158,7 +158,8 @@ pub async fn run( web::scope("/dockerhub") .service(crate::routes::dockerhub::search_namespaces) .service(crate::routes::dockerhub::list_repositories) - .service(crate::routes::dockerhub::list_tags), + .service(crate::routes::dockerhub::list_tags) + .service(crate::routes::dockerhub::log_event), ) .service( web::scope("/admin") @@ -252,7 +253,9 @@ pub async fn run( .service(crate::routes::marketplace::admin::approve_handler) .service(crate::routes::marketplace::admin::reject_handler) .service(crate::routes::marketplace::admin::unapprove_handler) - .service(crate::routes::marketplace::admin::security_scan_handler), + .service(crate::routes::marketplace::admin::security_scan_handler) + .service(crate::routes::marketplace::admin::pricing_handler) + .service(crate::routes::marketplace::admin::update_verifications_handler), ) .service( web::scope("/marketplace") diff --git a/tests/marketplace_integration.rs b/tests/marketplace_integration.rs index 6830548b..16319045 100644 --- a/tests/marketplace_integration.rs +++ b/tests/marketplace_integration.rs @@ -43,6 +43,7 @@ async fn test_deployment_free_template_allowed() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; // Should allow deployment of free template @@ -82,6 +83,7 @@ async fn test_deployment_plan_requirement_validated() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; // Should allow deployment (mock user has professional plan) @@ -125,6 +127,7 @@ async fn test_deployment_owned_paid_template_allowed() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; // The validator passes template.id to user_owns_template, but mock checks the string representation @@ -268,6 +271,7 @@ async fn test_deployment_validation_flow_with_connector() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; let result = validator @@ -299,6 +303,7 @@ async fn test_deployment_validation_flow_with_connector() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; let result = validator @@ -369,13 +374,13 @@ async fn test_plan_access_control() { // Mock always grants plan access let has_pro = connector - .user_has_plan("user1", "professional") + .user_has_plan("user1", "professional", None) .await .unwrap(); assert!(has_pro, "Mock grants all plan access"); let has_enterprise = connector - .user_has_plan("user1", "enterprise") + .user_has_plan("user1", "enterprise", None) .await .unwrap(); assert!(has_enterprise, "Mock grants all plan access"); @@ -411,6 +416,7 @@ async fn test_multiple_deployments_mixed_templates() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; let result = validator @@ -442,6 +448,7 @@ async fn test_multiple_deployments_mixed_templates() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; let result = validator @@ -478,6 +485,7 @@ async fn test_multiple_deployments_mixed_templates() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; // The result will depend on whether the validator can verify ownership @@ -531,6 +539,7 @@ fn test_template_status_values() { created_at: Some(Utc::now()), updated_at: Some(Utc::now()), approved_at: Some(Utc::now()), + verifications: serde_json::json!({}), }; assert_eq!(template.status, "approved");